diff --git a/apitest/src/test/java/bisq/apitest/method/MethodTest.java b/apitest/src/test/java/bisq/apitest/method/MethodTest.java index 0a43258d9a5..f64c2a5fe92 100644 --- a/apitest/src/test/java/bisq/apitest/method/MethodTest.java +++ b/apitest/src/test/java/bisq/apitest/method/MethodTest.java @@ -48,10 +48,12 @@ import bisq.proto.grpc.RegisterDisputeAgentRequest; import bisq.proto.grpc.RemoveWalletPasswordRequest; import bisq.proto.grpc.SendBsqRequest; +import bisq.proto.grpc.SendBtcRequest; import bisq.proto.grpc.SetTxFeeRatePreferenceRequest; import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.TakeOfferRequest; import bisq.proto.grpc.TradeInfo; +import bisq.proto.grpc.TxInfo; import bisq.proto.grpc.UnlockWalletRequest; import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest; import bisq.proto.grpc.WithdrawFundsRequest; @@ -160,8 +162,24 @@ protected final GetUnusedBsqAddressRequest createGetUnusedBsqAddressRequest() { return GetUnusedBsqAddressRequest.newBuilder().build(); } - protected final SendBsqRequest createSendBsqRequest(String address, String amount) { - return SendBsqRequest.newBuilder().setAddress(address).setAmount(amount).build(); + protected final SendBsqRequest createSendBsqRequest(String address, + String amount, + String txFeeRate) { + return SendBsqRequest.newBuilder() + .setAddress(address) + .setAmount(amount) + .setTxFeeRate(txFeeRate) + .build(); + } + + protected final SendBtcRequest createSendBtcRequest(String address, + String amount, + String txFeeRate) { + return SendBtcRequest.newBuilder() + .setAddress(address) + .setAmount(amount) + .setTxFeeRate(txFeeRate) + .build(); } protected final GetFundingAddressesRequest createGetFundingAddressesRequest() { @@ -247,9 +265,36 @@ protected final String getUnusedBsqAddress(BisqAppConfig bisqAppConfig) { return grpcStubs(bisqAppConfig).walletsService.getUnusedBsqAddress(createGetUnusedBsqAddressRequest()).getAddress(); } - protected final void sendBsq(BisqAppConfig bisqAppConfig, String address, String amount) { + protected final TxInfo sendBsq(BisqAppConfig bisqAppConfig, + String address, + String amount) { + return sendBsq(bisqAppConfig, address, amount, ""); + } + + protected final TxInfo sendBsq(BisqAppConfig bisqAppConfig, + String address, + String amount, + String txFeeRate) { + //noinspection ResultOfMethodCallIgnored + return grpcStubs(bisqAppConfig).walletsService.sendBsq(createSendBsqRequest(address, + amount, + txFeeRate)) + .getTxInfo(); + } + + protected final TxInfo sendBtc(BisqAppConfig bisqAppConfig, String address, String amount) { + return sendBtc(bisqAppConfig, address, amount, ""); + } + + protected final TxInfo sendBtc(BisqAppConfig bisqAppConfig, + String address, + String amount, + String txFeeRate) { //noinspection ResultOfMethodCallIgnored - grpcStubs(bisqAppConfig).walletsService.sendBsq(createSendBsqRequest(address, amount)); + return grpcStubs(bisqAppConfig).walletsService.sendBtc(createSendBtcRequest(address, + amount, + txFeeRate)) + .getTxInfo(); } protected final String getUnusedBtcAddress(BisqAppConfig bisqAppConfig) { diff --git a/apitest/src/test/java/bisq/apitest/method/payment/CreatePaymentAccountTest.java b/apitest/src/test/java/bisq/apitest/method/payment/CreatePaymentAccountTest.java index 9795eec28c4..2ef2809eb0b 100644 --- a/apitest/src/test/java/bisq/apitest/method/payment/CreatePaymentAccountTest.java +++ b/apitest/src/test/java/bisq/apitest/method/payment/CreatePaymentAccountTest.java @@ -746,7 +746,10 @@ public void testCreateTransferwiseAccount(TestInfo testInfo) { String jsonString = getCompletedFormAsJsonString(); TransferwiseAccount paymentAccount = (TransferwiseAccount) createPaymentAccount(alicedaemon, jsonString); verifyUserPayloadHasPaymentAccountWithId(paymentAccount.getId()); - verifyAccountTradeCurrencies(getAllTransferwiseCurrencies(), paymentAccount); + // As per commit 88f26f93241af698ae689bf081205d0f9dc929fa + // Do not autofill all currencies by default but keep all unselected. + // verifyAccountTradeCurrencies(getAllTransferwiseCurrencies(), paymentAccount); + assertEquals(0, paymentAccount.getTradeCurrencies().size()); verifyCommonFormEntries(paymentAccount); assertEquals(COMPLETED_FORM_MAP.get(PROPERTY_NAME_EMAIL), paymentAccount.getEmail()); log.debug("Deserialized {}: {}", paymentAccount.getClass().getSimpleName(), paymentAccount); diff --git a/apitest/src/test/java/bisq/apitest/method/wallet/BsqWalletTest.java b/apitest/src/test/java/bisq/apitest/method/wallet/BsqWalletTest.java index f9202af778a..2884555e3c7 100644 --- a/apitest/src/test/java/bisq/apitest/method/wallet/BsqWalletTest.java +++ b/apitest/src/test/java/bisq/apitest/method/wallet/BsqWalletTest.java @@ -108,7 +108,7 @@ public void testInitialBsqBalances(final TestInfo testInfo) { @Order(3) public void testSendBsqAndCheckBalancesBeforeGeneratingBtcBlock(final TestInfo testInfo) { String bobsBsqAddress = getUnusedBsqAddress(bobdaemon); - sendBsq(alicedaemon, bobsBsqAddress, SEND_BSQ_AMOUNT); + sendBsq(alicedaemon, bobsBsqAddress, SEND_BSQ_AMOUNT, "100"); sleep(2000); BsqBalanceInfo alicesBsqBalances = getBsqBalances(alicedaemon); diff --git a/apitest/src/test/java/bisq/apitest/method/wallet/BtcWalletTest.java b/apitest/src/test/java/bisq/apitest/method/wallet/BtcWalletTest.java index daee479b89a..a97e9a690da 100644 --- a/apitest/src/test/java/bisq/apitest/method/wallet/BtcWalletTest.java +++ b/apitest/src/test/java/bisq/apitest/method/wallet/BtcWalletTest.java @@ -20,6 +20,7 @@ import static bisq.cli.TableFormat.formatBtcBalanceInfoTbl; import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation; @@ -92,6 +93,38 @@ public void testFundAlicesBtcWallet(final TestInfo testInfo) { formatBtcBalanceInfoTbl(btcBalanceInfo)); } + @Test + @Order(3) + public void testAliceSendBTCToBob(TestInfo testInfo) { + String bobsBtcAddress = getUnusedBtcAddress(bobdaemon); + log.debug("Sending 5.5 BTC From Alice to Bob @ {}", bobsBtcAddress); + + sendBtc(alicedaemon, bobsBtcAddress, "5.50", "100"); + genBtcBlocksThenWait(1, 3000); + + BtcBalanceInfo alicesBalances = getBtcBalances(alicedaemon); + + log.debug("{} Alice's BTC Balances:\n{}", + testName(testInfo), + formatBtcBalanceInfoTbl(alicesBalances)); + bisq.core.api.model.BtcBalanceInfo alicesExpectedBalances = + bisq.core.api.model.BtcBalanceInfo.valueOf(700000000, + 0, + 700000000, + 0); + verifyBtcBalances(alicesExpectedBalances, alicesBalances); + + BtcBalanceInfo bobsBalances = getBtcBalances(bobdaemon); + log.debug("{} Bob's BTC Balances:\n{}", + testName(testInfo), + formatBtcBalanceInfoTbl(bobsBalances)); + // We cannot (?) predict the exact tx size and calculate how much in tx fees were + // deducted from the 5.5 BTC sent to Bob, but we do know Bob should have something + // between 15.49978000 and 15.49978100 BTC. + assertTrue(bobsBalances.getAvailableBalance() >= 1549978000); + assertTrue(bobsBalances.getAvailableBalance() <= 1549978100); + } + @AfterAll public static void tearDown() { tearDownScaffold(); diff --git a/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java b/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java index 6fadf3cc251..0fff4bf694d 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java +++ b/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java @@ -67,6 +67,7 @@ public void testBtcWalletFunding(final TestInfo testInfo) { btcWalletTest.testInitialBtcBalances(testInfo); btcWalletTest.testFundAlicesBtcWallet(testInfo); + btcWalletTest.testAliceSendBTCToBob(testInfo); } @Test diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index 44590cfbd6a..5a98f9a7eb8 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -40,9 +40,11 @@ import bisq.proto.grpc.RegisterDisputeAgentRequest; import bisq.proto.grpc.RemoveWalletPasswordRequest; import bisq.proto.grpc.SendBsqRequest; +import bisq.proto.grpc.SendBtcRequest; import bisq.proto.grpc.SetTxFeeRatePreferenceRequest; import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.TakeOfferRequest; +import bisq.proto.grpc.TxInfo; import bisq.proto.grpc.UnlockWalletRequest; import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest; import bisq.proto.grpc.WithdrawFundsRequest; @@ -110,6 +112,7 @@ private enum Method { getfundingaddresses, getunusedbsqaddress, sendbsq, + sendbtc, gettxfeerate, settxfeerate, unsettxfeerate, @@ -259,19 +262,46 @@ public static void run(String[] args) { throw new IllegalArgumentException("no bsq amount specified"); var amount = nonOptionArgs.get(2); + verifyStringIsValidDecimal(amount); - try { - Double.parseDouble(amount); - } catch (NumberFormatException e) { - throw new IllegalArgumentException(format("'%s' is not a number", amount)); - } + var txFeeRate = nonOptionArgs.size() == 4 ? nonOptionArgs.get(3) : ""; + if (!txFeeRate.isEmpty()) + verifyStringIsValidLong(txFeeRate); var request = SendBsqRequest.newBuilder() .setAddress(address) .setAmount(amount) + .setTxFeeRate(txFeeRate) .build(); - walletsService.sendBsq(request); - out.printf("%s BSQ sent to %s%n", amount, address); + var reply = walletsService.sendBsq(request); + TxInfo txInfo = reply.getTxInfo(); + out.printf("%s bsq sent to %s in tx %s%n", amount, address, txInfo.getId()); + return; + } + case sendbtc: { + if (nonOptionArgs.size() < 2) + throw new IllegalArgumentException("no btc address specified"); + + var address = nonOptionArgs.get(1); + + if (nonOptionArgs.size() < 3) + throw new IllegalArgumentException("no btc amount specified"); + + var amount = nonOptionArgs.get(2); + verifyStringIsValidDecimal(amount); + + var txFeeRate = nonOptionArgs.size() == 4 ? nonOptionArgs.get(3) : ""; + if (!txFeeRate.isEmpty()) + verifyStringIsValidLong(txFeeRate); + + var request = SendBtcRequest.newBuilder() + .setAddress(address) + .setAmount(amount) + .setTxFeeRate(txFeeRate) + .build(); + var reply = walletsService.sendBtc(request); + TxInfo txInfo = reply.getTxInfo(); + out.printf("%s btc sent to %s in tx %s%n", amount, address, txInfo.getId()); return; } case gettxfeerate: { @@ -284,13 +314,7 @@ public static void run(String[] args) { if (nonOptionArgs.size() < 2) throw new IllegalArgumentException("no tx fee rate specified"); - long txFeeRate; - try { - txFeeRate = Long.parseLong(nonOptionArgs.get(2)); - } catch (NumberFormatException e) { - throw new IllegalArgumentException(format("'%s' is not a number", nonOptionArgs.get(2))); - } - + var txFeeRate = toLong(nonOptionArgs.get(2)); var request = SetTxFeeRatePreferenceRequest.newBuilder() .setTxFeeRatePreference(txFeeRate) .build(); @@ -560,12 +584,7 @@ public static void run(String[] args) { if (nonOptionArgs.size() < 3) throw new IllegalArgumentException("no unlock timeout specified"); - long timeout; - try { - timeout = Long.parseLong(nonOptionArgs.get(2)); - } catch (NumberFormatException e) { - throw new IllegalArgumentException(format("'%s' is not a number", nonOptionArgs.get(2))); - } + var timeout = toLong(nonOptionArgs.get(2)); var request = UnlockWalletRequest.newBuilder() .setPassword(nonOptionArgs.get(1)) .setTimeout(timeout).build(); @@ -627,6 +646,30 @@ private static Method getMethodFromCmd(String methodName) { return Method.valueOf(methodName.toLowerCase()); } + private static void verifyStringIsValidDecimal(String param) { + try { + Double.parseDouble(param); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(format("'%s' is not a number", param)); + } + } + + private static void verifyStringIsValidLong(String param) { + try { + Long.parseLong(param); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(format("'%s' is not a number", param)); + } + } + + private static long toLong(String param) { + try { + return Long.parseLong(param); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(format("'%s' is not a number", param)); + } + } + private static File saveFileToDisk(String prefix, @SuppressWarnings("SameParameterValue") String suffix, String text) { @@ -663,7 +706,8 @@ private static void printHelp(OptionParser parser, PrintStream stream) { stream.format(rowFormat, "getaddressbalance", "address", "Get server wallet address balance"); stream.format(rowFormat, "getfundingaddresses", "", "Get BTC funding addresses"); stream.format(rowFormat, "getunusedbsqaddress", "", "Get unused BSQ address"); - stream.format(rowFormat, "sendbsq", "address, amount", "Send BSQ"); + stream.format(rowFormat, "sendbsq", "address, amount [,tx fee rate (sats/byte)]", "Send BSQ"); + stream.format(rowFormat, "sendbtc", "address, amount [,tx fee rate (sats/byte)]", "Send BTC"); stream.format(rowFormat, "gettxfeerate", "", "Get current tx fee rate in sats/byte"); stream.format(rowFormat, "settxfeerate", "satoshis (per byte)", "Set custom tx fee rate in sats/byte"); stream.format(rowFormat, "unsettxfeerate", "", "Unset custom tx fee rate"); diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java index bc5f8a4b200..522fe8fb243 100644 --- a/core/src/main/java/bisq/core/api/CoreApi.java +++ b/core/src/main/java/bisq/core/api/CoreApi.java @@ -34,10 +34,13 @@ import bisq.common.handlers.ResultHandler; import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Transaction; import javax.inject.Inject; import javax.inject.Singleton; +import com.google.common.util.concurrent.FutureCallback; + import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -246,8 +249,18 @@ public String getUnusedBsqAddress() { return walletsService.getUnusedBsqAddress(); } - public void sendBsq(String address, String amount, TxBroadcaster.Callback callback) { - walletsService.sendBsq(address, amount, callback); + public void sendBsq(String address, + String amount, + String txFeeRate, + TxBroadcaster.Callback callback) { + walletsService.sendBsq(address, amount, txFeeRate, callback); + } + + public void sendBtc(String address, + String amount, + String txFeeRate, + FutureCallback callback) { + walletsService.sendBtc(address, amount, txFeeRate, callback); } public void getTxFeeRate(ResultHandler resultHandler) { diff --git a/core/src/main/java/bisq/core/api/CoreWalletsService.java b/core/src/main/java/bisq/core/api/CoreWalletsService.java index 346c97a78de..2862f6c7e9b 100644 --- a/core/src/main/java/bisq/core/api/CoreWalletsService.java +++ b/core/src/main/java/bisq/core/api/CoreWalletsService.java @@ -23,7 +23,9 @@ import bisq.core.api.model.BtcBalanceInfo; import bisq.core.api.model.TxFeeRateInfo; import bisq.core.btc.Balances; +import bisq.core.btc.exceptions.AddressEntryException; import bisq.core.btc.exceptions.BsqChangeBelowDustException; +import bisq.core.btc.exceptions.InsufficientFundsException; import bisq.core.btc.exceptions.TransactionVerificationException; import bisq.core.btc.exceptions.WalletException; import bisq.core.btc.model.AddressEntry; @@ -35,7 +37,9 @@ import bisq.core.btc.wallet.WalletsManager; import bisq.core.provider.fee.FeeService; import bisq.core.user.Preferences; +import bisq.core.util.FormattingUtils; import bisq.core.util.coin.BsqFormatter; +import bisq.core.util.coin.CoinFormatter; import bisq.common.Timer; import bisq.common.UserThread; @@ -46,10 +50,12 @@ import org.bitcoinj.core.Coin; import org.bitcoinj.core.InsufficientMoneyException; import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionConfidence; import org.bitcoinj.crypto.KeyCrypterScrypt; import javax.inject.Inject; +import javax.inject.Named; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; @@ -64,6 +70,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -85,6 +92,7 @@ class CoreWalletsService { private final BsqTransferService bsqTransferService; private final BsqFormatter bsqFormatter; private final BtcWalletService btcWalletService; + private final CoinFormatter btcFormatter; private final FeeService feeService; private final Preferences preferences; @@ -103,6 +111,7 @@ public CoreWalletsService(Balances balances, BsqTransferService bsqTransferService, BsqFormatter bsqFormatter, BtcWalletService btcWalletService, + @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, FeeService feeService, Preferences preferences) { this.balances = balances; @@ -111,6 +120,7 @@ public CoreWalletsService(Balances balances, this.bsqTransferService = bsqTransferService; this.bsqFormatter = bsqFormatter; this.btcWalletService = btcWalletService; + this.btcFormatter = btcFormatter; this.feeService = feeService; this.preferences = preferences; } @@ -189,13 +199,27 @@ String getUnusedBsqAddress() { void sendBsq(String address, String amount, + String txFeeRate, TxBroadcaster.Callback callback) { + verifyWalletsAreAvailable(); + verifyEncryptedWalletIsUnlocked(); + try { LegacyAddress legacyAddress = getValidBsqLegacyAddress(address); - Coin receiverAmount = getValidBsqTransferAmount(amount); - BsqTransferModel model = bsqTransferService.getBsqTransferModel(legacyAddress, receiverAmount); + Coin receiverAmount = getValidTransferAmount(amount, bsqFormatter); + Coin txFeePerVbyte = getTxFeeRateFromParamOrPreferenceOrFeeService(txFeeRate); + BsqTransferModel model = bsqTransferService.getBsqTransferModel(legacyAddress, + receiverAmount, + txFeePerVbyte); + log.info("Sending {} BSQ to {} with tx fee rate {} sats/byte.", + amount, + address, + txFeePerVbyte.value); bsqTransferService.sendFunds(model, callback); - } catch (InsufficientMoneyException + } catch (InsufficientMoneyException ex) { + log.error("", ex); + throw new IllegalStateException("cannot send bsq due to insufficient funds", ex); + } catch (NumberFormatException | BsqChangeBelowDustException | TransactionVerificationException | WalletException ex) { @@ -204,6 +228,60 @@ void sendBsq(String address, } } + void sendBtc(String address, + String amount, + String txFeeRate, + FutureCallback callback) { + verifyWalletsAreAvailable(); + verifyEncryptedWalletIsUnlocked(); + + try { + Set fromAddresses = btcWalletService.getAddressEntriesForAvailableBalanceStream() + .map(AddressEntry::getAddressString) + .collect(Collectors.toSet()); + Coin receiverAmount = getValidTransferAmount(amount, btcFormatter); + Coin txFeePerVbyte = getTxFeeRateFromParamOrPreferenceOrFeeService(txFeeRate); + + // TODO Support feeExcluded (or included), default is fee included. + // See WithdrawalView # onWithdraw (and refactor). + Transaction feeEstimationTransaction = + btcWalletService.getFeeEstimationTransactionForMultipleAddresses(fromAddresses, + receiverAmount, + txFeePerVbyte); + if (feeEstimationTransaction == null) + throw new IllegalStateException("could not estimate the transaction fee"); + + Coin dust = btcWalletService.getDust(feeEstimationTransaction); + Coin fee = feeEstimationTransaction.getFee().add(dust); + if (dust.isPositive()) { + fee = feeEstimationTransaction.getFee().add(dust); + log.info("Dust txo ({} sats) was detected, the dust amount has been added to the fee (was {}, now {})", + dust.value, + feeEstimationTransaction.getFee(), + fee.value); + } + log.info("Sending {} BTC to {} with tx fee of {} sats (fee rate {} sats/byte).", + amount, + address, + fee.value, + txFeePerVbyte.value); + btcWalletService.sendFundsForMultipleAddresses(fromAddresses, + address, + receiverAmount, + fee, + null, + tempAesKey, + null, /* memo todo */ + callback); + } catch (AddressEntryException ex) { + log.error("", ex); + throw new IllegalStateException("cannot send btc from any addresses in wallet", ex); + } catch (InsufficientFundsException | InsufficientMoneyException ex) { + log.error("", ex); + throw new IllegalStateException("cannot send btc due to insufficient funds", ex); + } + } + void getTxFeeRate(ResultHandler resultHandler) { try { @SuppressWarnings({"unchecked", "Convert2MethodRef"}) @@ -423,15 +501,22 @@ private LegacyAddress getValidBsqLegacyAddress(String address) { } } - // Returns a Coin for the amount string, or a RuntimeException if invalid. - private Coin getValidBsqTransferAmount(String amount) { - Coin amountAsCoin = parseToCoin(amount, bsqFormatter); + // 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); if (amountAsCoin.isLessThan(getMinNonDustOutput())) - throw new IllegalStateException(format("%s bsq is an invalid send amount", amount)); + throw new IllegalStateException(format("%s is an invalid transfer amount", amount)); return amountAsCoin; } + private Coin getTxFeeRateFromParamOrPreferenceOrFeeService(String txFeeRate) { + // A non txFeeRate String value overrides the fee service and custom fee. + return txFeeRate.isEmpty() + ? btcWalletService.getTxFeeForWithdrawalPerVbyte() + : Coin.valueOf(Long.parseLong(txFeeRate)); + } + private KeyCrypterScrypt getKeyCrypterScrypt() { KeyCrypterScrypt keyCrypterScrypt = walletsManager.getKeyCrypterScrypt(); if (keyCrypterScrypt == null) diff --git a/core/src/main/java/bisq/core/api/model/TxInfo.java b/core/src/main/java/bisq/core/api/model/TxInfo.java new file mode 100644 index 00000000000..f1b24f6b0fa --- /dev/null +++ b/core/src/main/java/bisq/core/api/model/TxInfo.java @@ -0,0 +1,84 @@ +/* + * 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 org.bitcoinj.core.Transaction; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@EqualsAndHashCode +@Getter +public class TxInfo implements Payload { + + private final String id; + private final long outputSum; + private final long fee; + private final int size; + + public TxInfo(String id, long outputSum, long fee, int size) { + this.id = id; + this.outputSum = outputSum; + this.fee = fee; + this.size = size; + } + + public static TxInfo toTxInfo(Transaction transaction) { + if (transaction == null) + throw new IllegalStateException("server created a null transaction"); + + return new TxInfo(transaction.getTxId().toString(), + transaction.getOutputSum().value, + transaction.getFee().value, + transaction.getMessageSize()); + } + + ////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + ////////////////////////////////////////////////////////////////////////////////////// + + @Override + public bisq.proto.grpc.TxInfo toProtoMessage() { + return bisq.proto.grpc.TxInfo.newBuilder() + .setId(id) + .setOutputSum(outputSum) + .setFee(fee) + .setSize(size) + .build(); + } + + @SuppressWarnings("unused") + public static TxInfo fromProto(bisq.proto.grpc.TxInfo proto) { + return new TxInfo(proto.getId(), + proto.getOutputSum(), + proto.getFee(), + proto.getSize()); + } + + @Override + public String toString() { + return "TxInfo{" + "\n" + + " id='" + id + '\'' + "\n" + + ", outputSum=" + outputSum + " sats" + "\n" + + ", fee=" + fee + " sats" + "\n" + + ", size=" + size + " bytes" + "\n" + + '}'; + } +} diff --git a/core/src/main/java/bisq/core/btc/wallet/BsqTransferService.java b/core/src/main/java/bisq/core/btc/wallet/BsqTransferService.java index b6cc83e8c77..4558b0acf74 100644 --- a/core/src/main/java/bisq/core/btc/wallet/BsqTransferService.java +++ b/core/src/main/java/bisq/core/btc/wallet/BsqTransferService.java @@ -33,14 +33,15 @@ public BsqTransferService(WalletsManager walletsManager, } public BsqTransferModel getBsqTransferModel(LegacyAddress address, - Coin receiverAmount) + Coin receiverAmount, + Coin txFeePerVbyte) throws TransactionVerificationException, WalletException, BsqChangeBelowDustException, InsufficientMoneyException { Transaction preparedSendTx = bsqWalletService.getPreparedSendBsqTx(address.toString(), receiverAmount); - Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true); + Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, txFeePerVbyte); Transaction signedTx = bsqWalletService.signTx(txWithBtcFee); return new BsqTransferModel(address, diff --git a/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java b/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java index 449ee734c6c..d4be1563c4f 100644 --- a/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java @@ -439,8 +439,7 @@ public Transaction completePreparedVoteRevealTx(Transaction preparedTx, byte[] o // Add fee input to prepared BSQ send tx /////////////////////////////////////////////////////////////////////////////////////////// - - public Transaction completePreparedSendBsqTx(Transaction preparedBsqTx, boolean isSendTx) throws + public Transaction completePreparedSendBsqTx(Transaction preparedBsqTx) throws TransactionVerificationException, WalletException, InsufficientMoneyException { // preparedBsqTx has following structure: // inputs [1-n] BSQ inputs @@ -454,13 +453,26 @@ public Transaction completePreparedSendBsqTx(Transaction preparedBsqTx, boolean // outputs [0-1] BSQ change output // outputs [0-1] BTC change output // mining fee: BTC mining fee - return completePreparedBsqTx(preparedBsqTx, isSendTx, null); + Coin txFeePerVbyte = getTxFeeForWithdrawalPerVbyte(); + return completePreparedBsqTx(preparedBsqTx, null, txFeePerVbyte); + } + + public Transaction completePreparedSendBsqTx(Transaction preparedBsqTx, Coin txFeePerVbyte) throws + TransactionVerificationException, WalletException, InsufficientMoneyException { + return completePreparedBsqTx(preparedBsqTx, null, txFeePerVbyte); } public Transaction completePreparedBsqTx(Transaction preparedBsqTx, - boolean useCustomTxFee, @Nullable byte[] opReturnData) throws TransactionVerificationException, WalletException, InsufficientMoneyException { + Coin txFeePerVbyte = getTxFeeForWithdrawalPerVbyte(); + return completePreparedBsqTx(preparedBsqTx, opReturnData, txFeePerVbyte); + } + + public Transaction completePreparedBsqTx(Transaction preparedBsqTx, + @Nullable byte[] opReturnData, + Coin txFeePerVbyte) throws + TransactionVerificationException, WalletException, InsufficientMoneyException { // preparedBsqTx has following structure: // inputs [1-n] BSQ inputs @@ -487,8 +499,6 @@ public Transaction completePreparedBsqTx(Transaction preparedBsqTx, int sigSizePerInput = 106; // typical size for a tx with 2 inputs int txVsizeWithUnsignedInputs = 203; - // If useCustomTxFee we allow overriding the estimated fee from preferences - Coin txFeePerVbyte = useCustomTxFee ? getTxFeeForWithdrawalPerVbyte() : feeService.getTxFeePerVbyte(); // In case there are no change outputs we force a change by adding min dust to the BTC input Coin forcedChangeValue = Coin.ZERO; @@ -963,7 +973,7 @@ public void doubleSpendTransaction(String txId, Runnable resultHandler, ErrorMes } if (sendResult != null) { log.info("Broadcasting double spending transaction. " + sendResult.tx); - Futures.addCallback(sendResult.broadcastComplete, new FutureCallback() { + Futures.addCallback(sendResult.broadcastComplete, new FutureCallback<>() { @Override public void onSuccess(Transaction result) { log.info("Double spending transaction published. " + result); @@ -1043,6 +1053,14 @@ public Transaction getFeeEstimationTransaction(String fromAddress, public Transaction getFeeEstimationTransactionForMultipleAddresses(Set fromAddresses, Coin amount) throws AddressFormatException, AddressEntryException, InsufficientFundsException { + Coin txFeeForWithdrawalPerVbyte = getTxFeeForWithdrawalPerVbyte(); + return getFeeEstimationTransactionForMultipleAddresses(fromAddresses, amount, txFeeForWithdrawalPerVbyte); + } + + public Transaction getFeeEstimationTransactionForMultipleAddresses(Set fromAddresses, + Coin amount, + Coin txFeeForWithdrawalPerVbyte) + throws AddressFormatException, AddressEntryException, InsufficientFundsException { Set addressEntries = fromAddresses.stream() .map(address -> { Optional addressEntryOptional = findAddressEntry(address, AddressEntry.Context.AVAILABLE); @@ -1065,7 +1083,6 @@ public Transaction getFeeEstimationTransactionForMultipleAddresses(Set f int counter = 0; int txVsize = 0; Transaction tx; - Coin txFeeForWithdrawalPerVbyte = getTxFeeForWithdrawalPerVbyte(); do { counter++; fee = txFeeForWithdrawalPerVbyte.multiply(txVsize); @@ -1092,7 +1109,11 @@ public Transaction getFeeEstimationTransactionForMultipleAddresses(Set f } private boolean feeEstimationNotSatisfied(int counter, Transaction tx) { - long targetFee = getTxFeeForWithdrawalPerVbyte().multiply(tx.getVsize()).value; + return feeEstimationNotSatisfied(counter, tx, getTxFeeForWithdrawalPerVbyte()); + } + + private boolean feeEstimationNotSatisfied(int counter, Transaction tx, Coin txFeeForWithdrawalPerVbyte) { + long targetFee = txFeeForWithdrawalPerVbyte.multiply(tx.getVsize()).value; return counter < 10 && (tx.getFee().value < targetFee || tx.getFee().value - targetFee > 1000); @@ -1199,7 +1220,7 @@ private SendRequest getSendRequestForMultipleAddresses(Set fromAddresses Coin fee, @Nullable String changeAddress, @Nullable KeyParameter aesKey) throws - AddressFormatException, AddressEntryException, InsufficientMoneyException { + AddressFormatException, AddressEntryException { Transaction tx = new Transaction(params); final Coin netValue = amount.subtract(fee); checkArgument(Restrictions.isAboveDust(netValue), @@ -1232,12 +1253,12 @@ private SendRequest getSendRequestForMultipleAddresses(Set fromAddresses sendRequest.coinSelector = new BtcCoinSelector(walletsSetup.getAddressesFromAddressEntries(addressEntries), preferences.getIgnoreDustThreshold()); - Optional addressEntryOptional = Optional.empty(); - AddressEntry changeAddressAddressEntry = null; + Optional addressEntryOptional = Optional.empty(); + if (changeAddress != null) addressEntryOptional = findAddressEntry(changeAddress, AddressEntry.Context.AVAILABLE); - changeAddressAddressEntry = addressEntryOptional.orElseGet(() -> getFreshAddressEntry()); + AddressEntry changeAddressAddressEntry = addressEntryOptional.orElseGet(this::getFreshAddressEntry); checkNotNull(changeAddressAddressEntry, "change address must not be null"); sendRequest.changeAddress = changeAddressAddressEntry.getAddress(); return sendRequest; diff --git a/core/src/main/java/bisq/core/dao/governance/bond/lockup/LockupTxService.java b/core/src/main/java/bisq/core/dao/governance/bond/lockup/LockupTxService.java index 4a466811e0c..901793c74de 100644 --- a/core/src/main/java/bisq/core/dao/governance/bond/lockup/LockupTxService.java +++ b/core/src/main/java/bisq/core/dao/governance/bond/lockup/LockupTxService.java @@ -103,7 +103,7 @@ private Transaction getLockupTx(Coin lockupAmount, int lockTime, LockupReason lo throws InsufficientMoneyException, WalletException, TransactionVerificationException, IOException { byte[] opReturnData = BondConsensus.getLockupOpReturnData(lockTime, lockupReason, hash); Transaction preparedTx = bsqWalletService.getPreparedLockupTx(lockupAmount); - Transaction txWithBtcFee = btcWalletService.completePreparedBsqTx(preparedTx, true, opReturnData); + Transaction txWithBtcFee = btcWalletService.completePreparedBsqTx(preparedTx, opReturnData); Transaction transaction = bsqWalletService.signTx(txWithBtcFee); log.info("Lockup tx: " + transaction); return transaction; diff --git a/core/src/main/java/bisq/core/dao/governance/bond/unlock/UnlockTxService.java b/core/src/main/java/bisq/core/dao/governance/bond/unlock/UnlockTxService.java index 0defe5e4752..a4c6691cf67 100644 --- a/core/src/main/java/bisq/core/dao/governance/bond/unlock/UnlockTxService.java +++ b/core/src/main/java/bisq/core/dao/governance/bond/unlock/UnlockTxService.java @@ -103,7 +103,7 @@ private Transaction getUnlockTx(String lockupTxId) checkArgument(optionalLockupTxOutput.isPresent(), "lockupTxOutput must be present"); TxOutput lockupTxOutput = optionalLockupTxOutput.get(); Transaction preparedTx = bsqWalletService.getPreparedUnlockTx(lockupTxOutput); - Transaction txWithBtcFee = btcWalletService.completePreparedBsqTx(preparedTx, true, null); + Transaction txWithBtcFee = btcWalletService.completePreparedBsqTx(preparedTx, null); Transaction transaction = bsqWalletService.signTx(txWithBtcFee); log.info("Unlock tx: " + transaction); return transaction; diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java index b138a6c6931..b902e805edd 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java @@ -39,6 +39,8 @@ import bisq.proto.grpc.RemoveWalletPasswordRequest; import bisq.proto.grpc.SendBsqReply; import bisq.proto.grpc.SendBsqRequest; +import bisq.proto.grpc.SendBtcReply; +import bisq.proto.grpc.SendBtcRequest; import bisq.proto.grpc.SetTxFeeRatePreferenceReply; import bisq.proto.grpc.SetTxFeeRatePreferenceRequest; import bisq.proto.grpc.SetWalletPasswordReply; @@ -57,11 +59,17 @@ import javax.inject.Inject; +import com.google.common.util.concurrent.FutureCallback; + import java.util.List; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +import static bisq.core.api.model.TxInfo.toTxInfo; + @Slf4j class GrpcWalletsService extends WalletsGrpc.WalletsImplBase { @@ -145,24 +153,29 @@ public void getUnusedBsqAddress(GetUnusedBsqAddressRequest req, public void sendBsq(SendBsqRequest req, StreamObserver responseObserver) { try { - coreApi.sendBsq(req.getAddress(), req.getAmount(), new TxBroadcaster.Callback() { - @Override - public void onSuccess(Transaction tx) { - log.info("Successfully published BSQ tx: id {}, output sum {} sats, fee {} sats, size {} bytes", - tx.getTxId().toString(), - tx.getOutputSum(), - tx.getFee(), - tx.getMessageSize()); - var reply = SendBsqReply.newBuilder().build(); - responseObserver.onNext(reply); - responseObserver.onCompleted(); - } + coreApi.sendBsq(req.getAddress(), + req.getAmount(), + req.getTxFeeRate(), + new TxBroadcaster.Callback() { + @Override + public void onSuccess(Transaction tx) { + log.info("Successfully published BSQ tx: id {}, output sum {} sats, fee {} sats, size {} bytes", + tx.getTxId().toString(), + tx.getOutputSum(), + tx.getFee(), + tx.getMessageSize()); + var reply = SendBsqReply.newBuilder() + .setTxInfo(toTxInfo(tx).toProtoMessage()) + .build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } - @Override - public void onFailure(TxBroadcastException ex) { - throw new IllegalStateException(ex); - } - }); + @Override + public void onFailure(TxBroadcastException ex) { + throw new IllegalStateException(ex); + } + }); } catch (IllegalStateException cause) { var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); responseObserver.onError(ex); @@ -170,6 +183,45 @@ public void onFailure(TxBroadcastException ex) { } } + @Override + public void sendBtc(SendBtcRequest req, + StreamObserver responseObserver) { + try { + coreApi.sendBtc(req.getAddress(), + req.getAmount(), + req.getTxFeeRate(), + new FutureCallback<>() { + @Override + public void onSuccess(Transaction tx) { + if (tx != null) { + log.info("Successfully published BTC tx: id {}, output sum {} sats, fee {} sats, size {} bytes", + tx.getTxId().toString(), + tx.getOutputSum(), + tx.getFee(), + tx.getMessageSize()); + var reply = SendBtcReply.newBuilder() + .setTxInfo(toTxInfo(tx).toProtoMessage()) + .build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } else { + throw new IllegalStateException("btc transaction is null"); + } + } + + @Override + public void onFailure(@NotNull Throwable t) { + log.error("", t); + throw new IllegalStateException(t); + } + }); + } catch (IllegalStateException | IllegalArgumentException cause) { + var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); + responseObserver.onError(ex); + throw ex; + } + } + @Override public void getTxFeeRate(GetTxFeeRateRequest req, StreamObserver responseObserver) { diff --git a/desktop/src/main/java/bisq/desktop/main/dao/wallet/send/BsqSendView.java b/desktop/src/main/java/bisq/desktop/main/dao/wallet/send/BsqSendView.java index 917d1290aae..533064b62c1 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/wallet/send/BsqSendView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/wallet/send/BsqSendView.java @@ -247,7 +247,7 @@ private void addSendBsqGroup() { Coin receiverAmount = ParsingUtils.parseToCoin(amountInputTextField.getText(), bsqFormatter); try { Transaction preparedSendTx = bsqWalletService.getPreparedSendBsqTx(receiversAddressString, receiverAmount); - Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true); + Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx); Transaction signedTx = bsqWalletService.signTx(txWithBtcFee); Coin miningFee = signedTx.getFee(); int txVsize = signedTx.getVsize(); @@ -305,7 +305,7 @@ private void addSendBtcGroup() { Coin receiverAmount = bsqFormatter.parseToBTC(btcAmountInputTextField.getText()); try { Transaction preparedSendTx = bsqWalletService.getPreparedSendBtcTx(receiversAddressString, receiverAmount); - Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true); + Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx); Transaction signedTx = bsqWalletService.signTx(txWithBtcFee); Coin miningFee = signedTx.getFee(); diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index 517cfc4ea46..7bfaf219f73 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -287,6 +287,24 @@ message TradeInfo { string contractAsJson = 24; } +/////////////////////////////////////////////////////////////////////////////////////////// +// Transactions +/////////////////////////////////////////////////////////////////////////////////////////// + +message TxFeeRateInfo { + bool useCustomTxFeeRate = 1; + uint64 customTxFeeRate = 2; + uint64 feeServiceRate = 3; + uint64 lastFeeServiceRequestTs = 4; +} + +message TxInfo { + string id = 1; + uint64 outputSum = 2; + uint64 fee = 3; + int32 size = 4; +} + /////////////////////////////////////////////////////////////////////////////////////////// // Wallets /////////////////////////////////////////////////////////////////////////////////////////// @@ -300,6 +318,8 @@ service Wallets { } rpc SendBsq (SendBsqRequest) returns (SendBsqReply) { } + rpc SendBtc (SendBtcRequest) returns (SendBtcReply) { + } rpc GetTxFeeRate (GetTxFeeRateRequest) returns (GetTxFeeRateReply) { } rpc SetTxFeeRatePreference (SetTxFeeRatePreferenceRequest) returns (SetTxFeeRatePreferenceReply) { @@ -344,9 +364,21 @@ message GetUnusedBsqAddressReply { message SendBsqRequest { string address = 1; string amount = 2; + string txFeeRate = 3; } message SendBsqReply { + TxInfo txInfo = 1; +} + +message SendBtcRequest { + string address = 1; + string amount = 2; + string txFeeRate = 3; +} + +message SendBtcReply { + TxInfo txInfo = 1; } message GetTxFeeRateRequest { @@ -437,13 +469,6 @@ message AddressBalanceInfo { int64 numConfirmations = 3; } -message TxFeeRateInfo { - bool useCustomTxFeeRate = 1; - uint64 customTxFeeRate = 2; - uint64 feeServiceRate = 3; - uint64 lastFeeServiceRequestTs = 4; -} - /////////////////////////////////////////////////////////////////////////////////////////// // Version ///////////////////////////////////////////////////////////////////////////////////////////