diff --git a/apitest/docs/build-run.md b/apitest/docs/build-run.md index 308fe02cf66..58832bf4534 100644 --- a/apitest/docs/build-run.md +++ b/apitest/docs/build-run.md @@ -48,7 +48,7 @@ To run all test cases in a package: To run a single test case: - $ ./gradlew :apitest:test --tests "bisq.apitest.method.GetBalanceTest" -DrunApiTests=true + $ ./gradlew :apitest:test --tests "bisq.apitest.scenario.WalletTest" -DrunApiTests=true To run test cases from Intellij, add two JVM arguments to your JUnit launchers: diff --git a/apitest/src/test/java/bisq/apitest/method/GetBalanceTest.java b/apitest/src/test/java/bisq/apitest/method/GetBalanceTest.java deleted file mode 100644 index 1d44590837b..00000000000 --- a/apitest/src/test/java/bisq/apitest/method/GetBalanceTest.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * This file is part of Bisq. - * - * Bisq is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at - * your option) any later version. - * - * Bisq is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public - * License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Bisq. If not, see . - */ - -package bisq.apitest.method; - -import bisq.proto.grpc.GetBalanceRequest; - -import lombok.extern.slf4j.Slf4j; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; - -import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; -import static bisq.apitest.config.BisqAppConfig.alicedaemon; -import static bisq.apitest.config.BisqAppConfig.seednode; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; -import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation; - -@Disabled -@Slf4j -@TestMethodOrder(OrderAnnotation.class) -public class GetBalanceTest extends MethodTest { - - @BeforeAll - public static void setUp() { - try { - setUpScaffold(bitcoind, seednode, alicedaemon); - - // Have to generate 1 regtest block for alice's wallet to show 10 BTC balance. - bitcoinCli.generateBlocks(1); - - // Give the alicedaemon time to parse the new block. - MILLISECONDS.sleep(1500); - } catch (Exception ex) { - fail(ex); - } - } - - @Test - @Order(1) - public void testGetBalance() { - // All tests depend on the DAO / regtest environment, and Alice's wallet is - // initialized with 10 BTC during the scaffolding setup. - var balance = grpcStubs(alicedaemon).walletsService - .getBalance(GetBalanceRequest.newBuilder().build()).getBalance(); - assertEquals(1000000000, balance); - } - - @AfterAll - public static void tearDown() { - tearDownScaffold(); - } -} diff --git a/apitest/src/test/java/bisq/apitest/method/MethodTest.java b/apitest/src/test/java/bisq/apitest/method/MethodTest.java index 43073ba995b..72634b16785 100644 --- a/apitest/src/test/java/bisq/apitest/method/MethodTest.java +++ b/apitest/src/test/java/bisq/apitest/method/MethodTest.java @@ -17,21 +17,28 @@ package bisq.apitest.method; +import bisq.proto.grpc.AddressBalanceInfo; +import bisq.proto.grpc.BalancesInfo; +import bisq.proto.grpc.BsqBalanceInfo; +import bisq.proto.grpc.BtcBalanceInfo; import bisq.proto.grpc.CancelOfferRequest; import bisq.proto.grpc.ConfirmPaymentReceivedRequest; import bisq.proto.grpc.ConfirmPaymentStartedRequest; import bisq.proto.grpc.CreatePaymentAccountRequest; -import bisq.proto.grpc.GetBalanceRequest; +import bisq.proto.grpc.GetAddressBalanceRequest; +import bisq.proto.grpc.GetBalancesRequest; import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.GetOfferRequest; import bisq.proto.grpc.GetPaymentAccountsRequest; import bisq.proto.grpc.GetTradeRequest; +import bisq.proto.grpc.GetUnusedBsqAddressRequest; import bisq.proto.grpc.KeepFundsRequest; import bisq.proto.grpc.LockWalletRequest; import bisq.proto.grpc.MarketPriceRequest; import bisq.proto.grpc.OfferInfo; import bisq.proto.grpc.RegisterDisputeAgentRequest; import bisq.proto.grpc.RemoveWalletPasswordRequest; +import bisq.proto.grpc.SendBsqRequest; import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.TakeOfferRequest; import bisq.proto.grpc.TradeInfo; @@ -102,9 +109,12 @@ public static void startSupportingApps(boolean registerDisputeAgents, } // Convenience methods for building gRPC request objects + protected final GetBalancesRequest createGetBalancesRequest(String currencyCode) { + return GetBalancesRequest.newBuilder().setCurrencyCode(currencyCode).build(); + } - protected final GetBalanceRequest createBalanceRequest() { - return GetBalanceRequest.newBuilder().build(); + protected final GetAddressBalanceRequest createGetAddressBalanceRequest(String address) { + return GetAddressBalanceRequest.newBuilder().setAddress(address).build(); } protected final SetWalletPasswordRequest createSetWalletPasswordRequest(String password) { @@ -127,6 +137,14 @@ protected final LockWalletRequest createLockWalletRequest() { return LockWalletRequest.newBuilder().build(); } + protected final GetUnusedBsqAddressRequest createGetUnusedBsqAddressRequest() { + return GetUnusedBsqAddressRequest.newBuilder().build(); + } + + protected final SendBsqRequest createSendBsqRequest(String address, double amount) { + return SendBsqRequest.newBuilder().setAddress(address).setAmount(amount).build(); + } + protected final GetFundingAddressesRequest createGetFundingAddressesRequest() { return GetFundingAddressesRequest.newBuilder().build(); } @@ -143,8 +161,14 @@ protected final CancelOfferRequest createCancelOfferRequest(String offerId) { return CancelOfferRequest.newBuilder().setId(offerId).build(); } - protected final TakeOfferRequest createTakeOfferRequest(String offerId, String paymentAccountId) { - return TakeOfferRequest.newBuilder().setOfferId(offerId).setPaymentAccountId(paymentAccountId).build(); + protected final TakeOfferRequest createTakeOfferRequest(String offerId, + String paymentAccountId, + String takerFeeCurrencyCode) { + return TakeOfferRequest.newBuilder() + .setOfferId(offerId) + .setPaymentAccountId(paymentAccountId) + .setTakerFeeCurrencyCode(takerFeeCurrencyCode) + .build(); } protected final GetTradeRequest createGetTradeRequest(String tradeId) { @@ -173,9 +197,21 @@ protected final WithdrawFundsRequest createWithdrawFundsRequest(String tradeId, } // Convenience methods for calling frequently used & thoroughly tested gRPC services. + protected final BalancesInfo getBalances(BisqAppConfig bisqAppConfig, String currencyCode) { + return grpcStubs(bisqAppConfig).walletsService.getBalances( + createGetBalancesRequest(currencyCode)).getBalances(); + } + + protected final BsqBalanceInfo getBsqBalances(BisqAppConfig bisqAppConfig) { + return getBalances(bisqAppConfig, "bsq").getBsq(); + } + + protected final BtcBalanceInfo getBtcBalances(BisqAppConfig bisqAppConfig) { + return getBalances(bisqAppConfig, "btc").getBtc(); + } - protected final long getBalance(BisqAppConfig bisqAppConfig) { - return grpcStubs(bisqAppConfig).walletsService.getBalance(createBalanceRequest()).getBalance(); + protected final AddressBalanceInfo getAddressBalance(BisqAppConfig bisqAppConfig, String address) { + return grpcStubs(bisqAppConfig).walletsService.getAddressBalance(createGetAddressBalanceRequest(address)).getAddressBalanceInfo(); } protected final void unlockWallet(BisqAppConfig bisqAppConfig, String password, long timeout) { @@ -188,6 +224,15 @@ protected final void lockWallet(BisqAppConfig bisqAppConfig) { grpcStubs(bisqAppConfig).walletsService.lockWallet(createLockWalletRequest()); } + protected final String getUnusedBsqAddress(BisqAppConfig bisqAppConfig) { + return grpcStubs(bisqAppConfig).walletsService.getUnusedBsqAddress(createGetUnusedBsqAddressRequest()).getAddress(); + } + + protected final void sendBsq(BisqAppConfig bisqAppConfig, String address, double amount) { + //noinspection ResultOfMethodCallIgnored + grpcStubs(bisqAppConfig).walletsService.sendBsq(createSendBsqRequest(address, amount)); + } + protected final String getUnusedBtcAddress(BisqAppConfig bisqAppConfig) { //noinspection OptionalGetWithoutIsPresent return grpcStubs(bisqAppConfig).walletsService.getFundingAddresses(createGetFundingAddressesRequest()) diff --git a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java index fe9a98aaaae..cd9a5771a80 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java @@ -73,22 +73,35 @@ public static void setUp() { protected final OfferInfo createAliceOffer(PaymentAccount paymentAccount, String direction, String currencyCode, - long amount) { - return createMarketBasedPricedOffer(aliceStubs, paymentAccount, direction, currencyCode, amount); + long amount, + String makerFeeCurrencyCode) { + return createMarketBasedPricedOffer(aliceStubs, + paymentAccount, + direction, + currencyCode, + amount, + makerFeeCurrencyCode); } protected final OfferInfo createBobOffer(PaymentAccount paymentAccount, String direction, String currencyCode, - long amount) { - return createMarketBasedPricedOffer(bobStubs, paymentAccount, direction, currencyCode, amount); + long amount, + String makerFeeCurrencyCode) { + return createMarketBasedPricedOffer(bobStubs, + paymentAccount, + direction, + currencyCode, + amount, + makerFeeCurrencyCode); } protected final OfferInfo createMarketBasedPricedOffer(GrpcStubs grpcStubs, PaymentAccount paymentAccount, String direction, String currencyCode, - long amount) { + long amount, + String makerFeeCurrencyCode) { var req = CreateOfferRequest.newBuilder() .setPaymentAccountId(paymentAccount.getId()) .setDirection(direction) @@ -99,6 +112,7 @@ protected final OfferInfo createMarketBasedPricedOffer(GrpcStubs grpcStubs, .setMarketPriceMargin(0.00) .setPrice("0") .setBuyerSecurityDeposit(getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode(makerFeeCurrencyCode) .build(); return grpcStubs.offersService.createOffer(req).getOffer(); } diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java index 334fb022bb3..8ec90aedad3 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java @@ -54,6 +54,7 @@ public void testCancelOffer() { .setMarketPriceMargin(0.00) .setPrice("0") .setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode("bsq") .build(); // Create some offers. diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java index 72ff91f3115..daa85d5e8e9 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java @@ -38,6 +38,8 @@ @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class CreateOfferUsingFixedPriceTest extends AbstractOfferTest { + private static final String MAKER_FEE_CURRENCY_CODE = "bsq"; + @Test @Order(1) public void testCreateAUDBTCBuyOfferUsingFixedPrice16000() { @@ -51,6 +53,7 @@ public void testCreateAUDBTCBuyOfferUsingFixedPrice16000() { .setMarketPriceMargin(0.00) .setPrice("16000") .setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode(MAKER_FEE_CURRENCY_CODE) .build(); var newOffer = aliceStubs.offersService.createOffer(req).getOffer(); String newOfferId = newOffer.getId(); @@ -64,6 +67,7 @@ public void testCreateAUDBTCBuyOfferUsingFixedPrice16000() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("AUD", newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = getOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); @@ -76,6 +80,7 @@ public void testCreateAUDBTCBuyOfferUsingFixedPrice16000() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("AUD", newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); } @Test @@ -91,6 +96,7 @@ public void testCreateUSDBTCBuyOfferUsingFixedPrice100001234() { .setMarketPriceMargin(0.00) .setPrice("10000.1234") .setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode(MAKER_FEE_CURRENCY_CODE) .build(); var newOffer = aliceStubs.offersService.createOffer(req).getOffer(); String newOfferId = newOffer.getId(); @@ -104,6 +110,7 @@ public void testCreateUSDBTCBuyOfferUsingFixedPrice100001234() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("USD", newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = getOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); @@ -116,6 +123,7 @@ public void testCreateUSDBTCBuyOfferUsingFixedPrice100001234() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("USD", newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); } @Test @@ -131,6 +139,7 @@ public void testCreateEURBTCSellOfferUsingFixedPrice95001234() { .setMarketPriceMargin(0.00) .setPrice("9500.1234") .setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode(MAKER_FEE_CURRENCY_CODE) .build(); var newOffer = aliceStubs.offersService.createOffer(req).getOffer(); String newOfferId = newOffer.getId(); @@ -144,6 +153,7 @@ public void testCreateEURBTCSellOfferUsingFixedPrice95001234() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("EUR", newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = getOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); @@ -156,5 +166,6 @@ public void testCreateEURBTCSellOfferUsingFixedPrice95001234() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("EUR", newOffer.getCounterCurrencyCode()); + assertFalse(newOffer.getIsCurrencyForMakerFeeBtc()); } } diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java index 345bd130d71..dbf712c9355 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java @@ -50,6 +50,8 @@ public class CreateOfferUsingMarketPriceMarginTest extends AbstractOfferTest { private static final double MKT_PRICE_MARGIN_ERROR_TOLERANCE = 0.0050; // 0.50% private static final double MKT_PRICE_MARGIN_WARNING_TOLERANCE = 0.0001; // 0.01% + private static final String MAKER_FEE_CURRENCY_CODE = "btc"; + @Test @Order(1) public void testCreateUSDBTCBuyOffer5PctPriceMargin() { @@ -64,6 +66,7 @@ public void testCreateUSDBTCBuyOffer5PctPriceMargin() { .setMarketPriceMargin(priceMarginPctInput) .setPrice("0") .setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode(MAKER_FEE_CURRENCY_CODE) .build(); var newOffer = aliceStubs.offersService.createOffer(req).getOffer(); String newOfferId = newOffer.getId(); @@ -76,6 +79,7 @@ public void testCreateUSDBTCBuyOffer5PctPriceMargin() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("USD", newOffer.getCounterCurrencyCode()); + assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = getOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); @@ -87,6 +91,7 @@ public void testCreateUSDBTCBuyOffer5PctPriceMargin() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("USD", newOffer.getCounterCurrencyCode()); + assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); assertCalculatedPriceIsCorrect(newOffer, priceMarginPctInput); } @@ -105,6 +110,7 @@ public void testCreateNZDBTCBuyOfferMinus2PctPriceMargin() { .setMarketPriceMargin(priceMarginPctInput) .setPrice("0") .setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode(MAKER_FEE_CURRENCY_CODE) .build(); var newOffer = aliceStubs.offersService.createOffer(req).getOffer(); String newOfferId = newOffer.getId(); @@ -117,6 +123,7 @@ public void testCreateNZDBTCBuyOfferMinus2PctPriceMargin() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("NZD", newOffer.getCounterCurrencyCode()); + assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = getOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); @@ -128,6 +135,7 @@ public void testCreateNZDBTCBuyOfferMinus2PctPriceMargin() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("NZD", newOffer.getCounterCurrencyCode()); + assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); assertCalculatedPriceIsCorrect(newOffer, priceMarginPctInput); } @@ -146,6 +154,7 @@ public void testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin() { .setMarketPriceMargin(priceMarginPctInput) .setPrice("0") .setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode(MAKER_FEE_CURRENCY_CODE) .build(); var newOffer = aliceStubs.offersService.createOffer(req).getOffer(); @@ -159,6 +168,7 @@ public void testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("GBP", newOffer.getCounterCurrencyCode()); + assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = getOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); @@ -170,6 +180,7 @@ public void testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("GBP", newOffer.getCounterCurrencyCode()); + assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); assertCalculatedPriceIsCorrect(newOffer, priceMarginPctInput); } @@ -188,6 +199,7 @@ public void testCreateBRLBTCSellOffer6Point55PctPriceMargin() { .setMarketPriceMargin(priceMarginPctInput) .setPrice("0") .setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode(MAKER_FEE_CURRENCY_CODE) .build(); var newOffer = aliceStubs.offersService.createOffer(req).getOffer(); @@ -201,6 +213,7 @@ public void testCreateBRLBTCSellOffer6Point55PctPriceMargin() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("BRL", newOffer.getCounterCurrencyCode()); + assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); newOffer = getOffer(newOfferId); assertEquals(newOfferId, newOffer.getId()); @@ -212,6 +225,7 @@ public void testCreateBRLBTCSellOffer6Point55PctPriceMargin() { assertEquals(alicesDummyAcct.getId(), newOffer.getPaymentAccountId()); assertEquals("BTC", newOffer.getBaseCurrencyCode()); assertEquals("BRL", newOffer.getCounterCurrencyCode()); + assertTrue(newOffer.getIsCurrencyForMakerFeeBtc()); assertCalculatedPriceIsCorrect(newOffer, priceMarginPctInput); } diff --git a/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java index 3ddd8cb3030..0225238b6a9 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java @@ -52,6 +52,7 @@ public void testAmtTooLargeShouldThrowException() { .setMarketPriceMargin(0.00) .setPrice("10000.0000") .setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()) + .setMakerFeeCurrencyCode("bsq") .build(); @SuppressWarnings("ResultOfMethodCallIgnored") Throwable exception = assertThrows(StatusRuntimeException.class, () -> diff --git a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java index e8537206dbf..2b88e4f2700 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java @@ -27,13 +27,25 @@ public static void initStaticFixtures() { EXPECTED_PROTOCOL_STATUS.init(); } - protected final TradeInfo takeAlicesOffer(String offerId, String paymentAccountId) { - return bobStubs.tradesService.takeOffer(createTakeOfferRequest(offerId, paymentAccountId)).getTrade(); + protected final TradeInfo takeAlicesOffer(String offerId, + String paymentAccountId, + String takerFeeCurrencyCode) { + return bobStubs.tradesService.takeOffer( + createTakeOfferRequest(offerId, + paymentAccountId, + takerFeeCurrencyCode)) + .getTrade(); } @SuppressWarnings("unused") - protected final TradeInfo takeBobsOffer(String offerId, String paymentAccountId) { - return aliceStubs.tradesService.takeOffer(createTakeOfferRequest(offerId, paymentAccountId)).getTrade(); + protected final TradeInfo takeBobsOffer(String offerId, + String paymentAccountId, + String takerFeeCurrencyCode) { + return aliceStubs.tradesService.takeOffer( + createTakeOfferRequest(offerId, + paymentAccountId, + takerFeeCurrencyCode)) + .getTrade(); } protected final void verifyExpectedProtocolStatus(TradeInfo trade) { diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java index 3561787c454..ffbf75ffee6 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java @@ -17,6 +17,8 @@ package bisq.apitest.method.trade; +import bisq.proto.grpc.BtcBalanceInfo; + import io.grpc.StatusRuntimeException; import lombok.extern.slf4j.Slf4j; @@ -37,6 +39,7 @@ import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED; import static bisq.core.trade.Trade.State.*; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.fail; import static protobuf.Offer.State.OFFER_FEE_PAID; @@ -49,6 +52,9 @@ public class TakeBuyBTCOfferTest extends AbstractTradeTest { // Alice is buyer, Bob is seller. + // Maker and Taker fees are in BSQ. + private static final String TRADE_FEE_CURRENCY_CODE = "bsq"; + @Test @Order(1) public void testTakeAlicesBuyOffer(final TestInfo testInfo) { @@ -56,17 +62,20 @@ public void testTakeAlicesBuyOffer(final TestInfo testInfo) { var alicesOffer = createAliceOffer(alicesDummyAcct, "buy", "usd", - 12500000); + 12500000, + TRADE_FEE_CURRENCY_CODE); var offerId = alicesOffer.getId(); + assertFalse(alicesOffer.getIsCurrencyForMakerFeeBtc()); // Wait for Alice's AddToOfferBook task. // Wait times vary; my logs show >= 2 second delay. - sleep(3000); + sleep(3000); // TODO loop instead of hard code wait time assertEquals(1, getOpenOffersCount(aliceStubs, "buy", "usd")); - var trade = takeAlicesOffer(offerId, bobsDummyAcct.getId()); + var trade = takeAlicesOffer(offerId, bobsDummyAcct.getId(), TRADE_FEE_CURRENCY_CODE); assertNotNull(trade); assertEquals(offerId, trade.getTradeId()); + assertFalse(trade.getIsCurrencyForTakerFeeBtc()); // Cache the trade id for the other tests. tradeId = trade.getTradeId(); @@ -147,8 +156,9 @@ public void testAlicesKeepFunds(final TestInfo testInfo) { .setPhase(PAYOUT_PUBLISHED); verifyExpectedProtocolStatus(trade); logTrade(log, testInfo, "Alice's view after keeping funds", trade); + BtcBalanceInfo currentBalance = getBtcBalances(bobdaemon); log.info("{} Alice's current available balance: {} BTC", testName(testInfo), - formatSatoshis(getBalance(alicedaemon))); + formatSatoshis(currentBalance.getAvailableBalance())); } } diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java index 35e13c002fa..2278ce315cd 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java @@ -17,6 +17,8 @@ package bisq.apitest.method.trade; +import bisq.proto.grpc.BtcBalanceInfo; + import io.grpc.StatusRuntimeException; import lombok.extern.slf4j.Slf4j; @@ -35,6 +37,7 @@ import static bisq.core.trade.Trade.State.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static protobuf.Offer.State.OFFER_FEE_PAID; import static protobuf.OpenOffer.State.AVAILABLE; @@ -46,6 +49,9 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest { // Alice is seller, Bob is buyer. + // Maker and Taker fees are in BTC. + private static final String TRADE_FEE_CURRENCY_CODE = "btc"; + @Test @Order(1) public void testTakeAlicesSellOffer(final TestInfo testInfo) { @@ -53,18 +59,21 @@ public void testTakeAlicesSellOffer(final TestInfo testInfo) { var alicesOffer = createAliceOffer(alicesDummyAcct, "sell", "usd", - 12500000); + 12500000, + TRADE_FEE_CURRENCY_CODE); var offerId = alicesOffer.getId(); + assertTrue(alicesOffer.getIsCurrencyForMakerFeeBtc()); // Wait for Alice's AddToOfferBook task. // Wait times vary; my logs show >= 2 second delay, but taking sell offers // seems to require more time to prepare. - sleep(3000); + sleep(3000); // TODO loop instead of hard code wait time assertEquals(1, getOpenOffersCount(bobStubs, "sell", "usd")); - var trade = takeAlicesOffer(offerId, bobsDummyAcct.getId()); + var trade = takeAlicesOffer(offerId, bobsDummyAcct.getId(), TRADE_FEE_CURRENCY_CODE); assertNotNull(trade); assertEquals(offerId, trade.getTradeId()); + assertTrue(trade.getIsCurrencyForTakerFeeBtc()); // Cache the trade id for the other tests. tradeId = trade.getTradeId(); @@ -148,8 +157,9 @@ public void testBobsBtcWithdrawalToExternalAddress(final TestInfo testInfo) { .setWithdrawn(true); verifyExpectedProtocolStatus(trade); logTrade(log, testInfo, "Bob's view after withdrawing funds to external wallet", trade); + BtcBalanceInfo currentBalance = getBtcBalances(bobdaemon); log.info("{} Bob's current available balance: {} BTC", testName(testInfo), - formatSatoshis(getBalance(bobdaemon))); + formatSatoshis(currentBalance.getAvailableBalance())); } } diff --git a/apitest/src/test/java/bisq/apitest/method/wallet/BsqWalletTest.java b/apitest/src/test/java/bisq/apitest/method/wallet/BsqWalletTest.java new file mode 100644 index 00000000000..d074793e5d4 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/wallet/BsqWalletTest.java @@ -0,0 +1,244 @@ +package bisq.apitest.method.wallet; + +import bisq.proto.grpc.BsqBalanceInfo; + +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; + +import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; +import static bisq.apitest.config.BisqAppConfig.alicedaemon; +import static bisq.apitest.config.BisqAppConfig.arbdaemon; +import static bisq.apitest.config.BisqAppConfig.bobdaemon; +import static bisq.apitest.config.BisqAppConfig.seednode; +import static bisq.cli.TableFormat.formatBsqBalanceInfoTbl; +import static org.bitcoinj.core.NetworkParameters.PAYMENT_PROTOCOL_ID_MAINNET; +import static org.bitcoinj.core.NetworkParameters.PAYMENT_PROTOCOL_ID_REGTEST; +import static org.bitcoinj.core.NetworkParameters.PAYMENT_PROTOCOL_ID_TESTNET; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation; + + + +import bisq.apitest.config.BisqAppConfig; +import bisq.apitest.method.MethodTest; + +@Disabled +@Slf4j +@TestMethodOrder(OrderAnnotation.class) +public class BsqWalletTest extends MethodTest { + + // Alice's regtest BSQ wallet is initialized with 1,000,000 BSQ. + private static final bisq.core.api.model.BsqBalanceInfo ALICES_INITIAL_BSQ_BALANCES = + expectedBsqBalanceModel(100000000, + 0, + 0, + 0, + 0, + 0); + + // Bob's regtest BSQ wallet is initialized with 1,500,000 BSQ. + private static final bisq.core.api.model.BsqBalanceInfo BOBS_INITIAL_BSQ_BALANCES = + expectedBsqBalanceModel(150000000, + 0, + 0, + 0, + 0, + 0); + + private static final double SEND_BSQ_AMOUNT = 25000.50; + + @BeforeAll + public static void setUp() { + startSupportingApps(false, + true, + bitcoind, + seednode, + arbdaemon, + alicedaemon, + bobdaemon); + } + + @Test + @Order(1) + public void testGetUnusedBsqAddress() { + var request = createGetUnusedBsqAddressRequest(); + + String address = grpcStubs(alicedaemon).walletsService.getUnusedBsqAddress(request).getAddress(); + assertFalse(address.isEmpty()); + assertTrue(address.startsWith("B")); + + NetworkParameters networkParameters = LegacyAddress.getParametersFromAddress(address.substring(1)); + String addressNetwork = networkParameters.getPaymentProtocolId(); + assertNotEquals(PAYMENT_PROTOCOL_ID_MAINNET, addressNetwork); + // TODO Fix bug causing the regtest bsq address network to be evaluated as 'testnet' here. + assertTrue(addressNetwork.equals(PAYMENT_PROTOCOL_ID_TESTNET) + || addressNetwork.equals(PAYMENT_PROTOCOL_ID_REGTEST)); + } + + @Test + @Order(2) + public void testInitialBsqBalances(final TestInfo testInfo) { + BsqBalanceInfo alicesBsqBalances = getBsqBalances(alicedaemon); + log.info("{} -> Alice's BSQ Initial Balances -> \n{}", + testName(testInfo), + formatBsqBalanceInfoTbl(alicesBsqBalances)); + verifyBsqBalances(ALICES_INITIAL_BSQ_BALANCES, alicesBsqBalances); + + BsqBalanceInfo bobsBsqBalances = getBsqBalances(bobdaemon); + log.info("{} -> Bob's BSQ Initial Balances -> \n{}", + testName(testInfo), + formatBsqBalanceInfoTbl(bobsBsqBalances)); + verifyBsqBalances(BOBS_INITIAL_BSQ_BALANCES, bobsBsqBalances); + } + + @Test + @Order(3) + public void testSendBsqAndCheckBalancesBeforeGeneratingBtcBlock(final TestInfo testInfo) { + String bobsBsqAddress = getUnusedBsqAddress(bobdaemon); + sendBsq(alicedaemon, bobsBsqAddress, SEND_BSQ_AMOUNT); + sleep(2000); + + BsqBalanceInfo alicesBsqBalances = getBsqBalances(alicedaemon); + BsqBalanceInfo bobsBsqBalances = waitForNonZeroUnverifiedBalance(bobdaemon); + + log.info("BSQ Balances Before BTC Block Gen..."); + printBobAndAliceBsqBalances(testInfo, + bobsBsqBalances, + alicesBsqBalances, + alicedaemon); + + verifyBsqBalances(expectedBsqBalanceModel(150000000, + 2500050, + 0, + 0, + 0, + 0), + bobsBsqBalances); + + verifyBsqBalances(expectedBsqBalanceModel(97499950, + 97499950, + 97499950, + 0, + 0, + 0), + alicesBsqBalances); + } + + @Test + @Order(4) + public void testBalancesAfterSendingBsqAndGeneratingBtcBlock(final TestInfo testInfo) { + // There is a wallet persist delay; we have to + // wait for both wallets to be saved to disk. + genBtcBlocksThenWait(1, 4000); + + BsqBalanceInfo alicesBsqBalances = getBsqBalances(alicedaemon); + BsqBalanceInfo bobsBsqBalances = waitForNewAvailableConfirmedBalance(bobdaemon, 150000000); + + log.info("See Available Confirmed BSQ Balances..."); + printBobAndAliceBsqBalances(testInfo, + bobsBsqBalances, + alicesBsqBalances, + alicedaemon); + + verifyBsqBalances(expectedBsqBalanceModel(152500050, + 0, + 0, + 0, + 0, + 0), + bobsBsqBalances); + + verifyBsqBalances(expectedBsqBalanceModel(97499950, + 0, + 0, + 0, + 0, + 0), + alicesBsqBalances); + } + + @AfterAll + public static void tearDown() { + tearDownScaffold(); + } + + private void verifyBsqBalances(bisq.core.api.model.BsqBalanceInfo expected, + BsqBalanceInfo actual) { + assertEquals(expected.getAvailableConfirmedBalance(), actual.getAvailableConfirmedBalance()); + assertEquals(expected.getUnverifiedBalance(), actual.getUnverifiedBalance()); + assertEquals(expected.getUnconfirmedChangeBalance(), actual.getUnconfirmedChangeBalance()); + assertEquals(expected.getLockedForVotingBalance(), actual.getLockedForVotingBalance()); + assertEquals(expected.getLockupBondsBalance(), actual.getLockupBondsBalance()); + assertEquals(expected.getUnlockingBondsBalance(), actual.getUnlockingBondsBalance()); + } + + private BsqBalanceInfo waitForNonZeroUnverifiedBalance(BisqAppConfig daemon) { + // A BSQ recipient needs to wait for her daemon to detect a new tx. + // Loop here until her unverifiedBalance != 0, or give up after 15 seconds. + // A slow test is preferred over a flaky test. + BsqBalanceInfo bsqBalance = getBsqBalances(daemon); + for (int numRequests = 1; numRequests <= 15 && bsqBalance.getUnverifiedBalance() == 0; numRequests++) { + sleep(1000); + bsqBalance = getBsqBalances(daemon); + } + return bsqBalance; + } + + private BsqBalanceInfo waitForNewAvailableConfirmedBalance(BisqAppConfig daemon, + long staleBalance) { + BsqBalanceInfo bsqBalance = getBsqBalances(daemon); + for (int numRequests = 1; + numRequests <= 15 && bsqBalance.getAvailableConfirmedBalance() == staleBalance; + numRequests++) { + sleep(1000); + bsqBalance = getBsqBalances(daemon); + } + return bsqBalance; + } + + @SuppressWarnings("SameParameterValue") + private void printBobAndAliceBsqBalances(final TestInfo testInfo, + BsqBalanceInfo bobsBsqBalances, + BsqBalanceInfo alicesBsqBalances, + BisqAppConfig senderApp) { + log.info("{} -> Bob's BSQ Balances After {} {} BSQ-> \n{}", + testName(testInfo), + senderApp.equals(bobdaemon) ? "Sending" : "Receiving", + SEND_BSQ_AMOUNT, + formatBsqBalanceInfoTbl(bobsBsqBalances)); + + log.info("{} -> Alice's Balances After {} {} BSQ-> \n{}", + testName(testInfo), + senderApp.equals(alicedaemon) ? "Sending" : "Receiving", + SEND_BSQ_AMOUNT, + formatBsqBalanceInfoTbl(alicesBsqBalances)); + } + + @SuppressWarnings("SameParameterValue") + private static bisq.core.api.model.BsqBalanceInfo expectedBsqBalanceModel(long availableConfirmedBalance, + long unverifiedBalance, + long unconfirmedChangeBalance, + long lockedForVotingBalance, + long lockupBondsBalance, + long unlockingBondsBalance) { + return bisq.core.api.model.BsqBalanceInfo.valueOf(availableConfirmedBalance, + unverifiedBalance, + unconfirmedChangeBalance, + lockedForVotingBalance, + lockupBondsBalance, + unlockingBondsBalance); + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/wallet/BtcWalletTest.java b/apitest/src/test/java/bisq/apitest/method/wallet/BtcWalletTest.java new file mode 100644 index 00000000000..daee479b89a --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/wallet/BtcWalletTest.java @@ -0,0 +1,107 @@ +package bisq.apitest.method.wallet; + +import bisq.proto.grpc.BtcBalanceInfo; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; + +import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; +import static bisq.apitest.config.BisqAppConfig.alicedaemon; +import static bisq.apitest.config.BisqAppConfig.bobdaemon; +import static bisq.apitest.config.BisqAppConfig.seednode; +import static bisq.cli.TableFormat.formatAddressBalanceTbl; +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.MethodOrderer.OrderAnnotation; + + + +import bisq.apitest.method.MethodTest; + +@Disabled +@Slf4j +@TestMethodOrder(OrderAnnotation.class) +public class BtcWalletTest extends MethodTest { + + // All api tests depend on the DAO / regtest environment, and Bob & Alice's wallets + // are initialized with 10 BTC during the scaffolding setup. + private static final bisq.core.api.model.BtcBalanceInfo INITIAL_BTC_BALANCES = + bisq.core.api.model.BtcBalanceInfo.valueOf(1000000000, + 0, + 1000000000, + 0); + + @BeforeAll + public static void setUp() { + startSupportingApps(false, + true, + bitcoind, + seednode, + alicedaemon, + bobdaemon); + } + + @Test + @Order(1) + public void testInitialBtcBalances(final TestInfo testInfo) { + // Bob & Alice's regtest Bisq wallets were initialized with 10 BTC. + + BtcBalanceInfo alicesBalances = getBtcBalances(alicedaemon); + log.info("{} Alice's BTC Balances:\n{}", testName(testInfo), formatBtcBalanceInfoTbl(alicesBalances)); + + BtcBalanceInfo bobsBalances = getBtcBalances(bobdaemon); + log.info("{} Bob's BTC Balances:\n{}", testName(testInfo), formatBtcBalanceInfoTbl(bobsBalances)); + + assertEquals(INITIAL_BTC_BALANCES.getAvailableBalance(), alicesBalances.getAvailableBalance()); + assertEquals(INITIAL_BTC_BALANCES.getAvailableBalance(), bobsBalances.getAvailableBalance()); + } + + @Test + @Order(2) + public void testFundAlicesBtcWallet(final TestInfo testInfo) { + String newAddress = getUnusedBtcAddress(alicedaemon); + bitcoinCli.sendToAddress(newAddress, "2.5"); + genBtcBlocksThenWait(1, 1500); + + BtcBalanceInfo btcBalanceInfo = getBtcBalances(alicedaemon); + // New balance is 12.5 BTC + assertEquals(1250000000, btcBalanceInfo.getAvailableBalance()); + + log.info("{} -> Alice's Funded Address Balance -> \n{}", + testName(testInfo), + formatAddressBalanceTbl(singletonList(getAddressBalance(alicedaemon, newAddress)))); + + // New balance is 12.5 BTC + btcBalanceInfo = getBtcBalances(alicedaemon); + bisq.core.api.model.BtcBalanceInfo alicesExpectedBalances = + bisq.core.api.model.BtcBalanceInfo.valueOf(1250000000, + 0, + 1250000000, + 0); + verifyBtcBalances(alicesExpectedBalances, btcBalanceInfo); + log.info("{} -> Alice's BTC Balances After Sending 2.5 BTC -> \n{}", + testName(testInfo), + formatBtcBalanceInfoTbl(btcBalanceInfo)); + } + + @AfterAll + public static void tearDown() { + tearDownScaffold(); + } + + private void verifyBtcBalances(bisq.core.api.model.BtcBalanceInfo expected, + BtcBalanceInfo actual) { + assertEquals(expected.getAvailableBalance(), actual.getAvailableBalance()); + assertEquals(expected.getReservedBalance(), actual.getReservedBalance()); + assertEquals(expected.getTotalAvailableBalance(), actual.getTotalAvailableBalance()); + assertEquals(expected.getLockedBalance(), actual.getLockedBalance()); + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/WalletProtectionTest.java b/apitest/src/test/java/bisq/apitest/method/wallet/WalletProtectionTest.java similarity index 89% rename from apitest/src/test/java/bisq/apitest/method/WalletProtectionTest.java rename to apitest/src/test/java/bisq/apitest/method/wallet/WalletProtectionTest.java index 08547e9ebb9..f5dabd90593 100644 --- a/apitest/src/test/java/bisq/apitest/method/WalletProtectionTest.java +++ b/apitest/src/test/java/bisq/apitest/method/wallet/WalletProtectionTest.java @@ -1,4 +1,4 @@ -package bisq.apitest.method; +package bisq.apitest.method.wallet; import io.grpc.StatusRuntimeException; @@ -18,6 +18,10 @@ import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation; + + +import bisq.apitest.method.MethodTest; + @SuppressWarnings("ResultOfMethodCallIgnored") @Disabled @Slf4j @@ -44,7 +48,7 @@ public void testSetWalletPassword() { @Test @Order(2) public void testGetBalanceOnEncryptedWalletShouldThrowException() { - Throwable exception = assertThrows(StatusRuntimeException.class, () -> getBalance(alicedaemon)); + Throwable exception = assertThrows(StatusRuntimeException.class, () -> getBtcBalances(alicedaemon)); assertEquals("UNKNOWN: wallet is locked", exception.getMessage()); } @@ -53,9 +57,9 @@ public void testGetBalanceOnEncryptedWalletShouldThrowException() { public void testUnlockWalletFor4Seconds() { var request = createUnlockWalletRequest("first-password", 4); grpcStubs(alicedaemon).walletsService.unlockWallet(request); - getBalance(alicedaemon); // should not throw 'wallet locked' exception + getBtcBalances(alicedaemon); // should not throw 'wallet locked' exception sleep(4500); // let unlock timeout expire - Throwable exception = assertThrows(StatusRuntimeException.class, () -> getBalance(alicedaemon)); + Throwable exception = assertThrows(StatusRuntimeException.class, () -> getBtcBalances(alicedaemon)); assertEquals("UNKNOWN: wallet is locked", exception.getMessage()); } @@ -65,7 +69,7 @@ public void testGetBalanceAfterUnlockTimeExpiryShouldThrowException() { var request = createUnlockWalletRequest("first-password", 3); grpcStubs(alicedaemon).walletsService.unlockWallet(request); sleep(4000); // let unlock timeout expire - Throwable exception = assertThrows(StatusRuntimeException.class, () -> getBalance(alicedaemon)); + Throwable exception = assertThrows(StatusRuntimeException.class, () -> getBtcBalances(alicedaemon)); assertEquals("UNKNOWN: wallet is locked", exception.getMessage()); } @@ -75,7 +79,7 @@ public void testLockWalletBeforeUnlockTimeoutExpiry() { unlockWallet(alicedaemon, "first-password", 60); var request = createLockWalletRequest(); grpcStubs(alicedaemon).walletsService.lockWallet(request); - Throwable exception = assertThrows(StatusRuntimeException.class, () -> getBalance(alicedaemon)); + Throwable exception = assertThrows(StatusRuntimeException.class, () -> getBtcBalances(alicedaemon)); assertEquals("UNKNOWN: wallet is locked", exception.getMessage()); } @@ -95,7 +99,7 @@ public void testUnlockWalletTimeoutOverride() { sleep(500); // override unlock timeout after 0.5s unlockWallet(alicedaemon, "first-password", 6); sleep(5000); - getBalance(alicedaemon); // getbalance 5s after resetting unlock timeout to 6s + getBtcBalances(alicedaemon); // getbalance 5s after overriding timeout to 6s } @Test @@ -105,7 +109,7 @@ public void testSetNewWalletPassword() { "first-password", "second-password"); grpcStubs(alicedaemon).walletsService.setWalletPassword(request); unlockWallet(alicedaemon, "second-password", 2); - getBalance(alicedaemon); + getBtcBalances(alicedaemon); sleep(2500); // allow time for wallet save } @@ -124,7 +128,7 @@ public void testSetNewWalletPasswordWithIncorrectNewPasswordShouldThrowException public void testRemoveNewWalletPassword() { var request = createRemoveWalletPasswordRequest("second-password"); grpcStubs(alicedaemon).walletsService.removeWalletPassword(request); - getBalance(alicedaemon); // should not throw 'wallet locked' exception + getBtcBalances(alicedaemon); // should not throw 'wallet locked' exception } @AfterAll diff --git a/apitest/src/test/java/bisq/apitest/scenario/FundWalletScenarioTest.java b/apitest/src/test/java/bisq/apitest/scenario/FundWalletScenarioTest.java deleted file mode 100644 index 4b7d40f516c..00000000000 --- a/apitest/src/test/java/bisq/apitest/scenario/FundWalletScenarioTest.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * This file is part of Bisq. - * - * Bisq is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at - * your option) any later version. - * - * Bisq is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public - * License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Bisq. If not, see . - */ - -package bisq.apitest.scenario; - -import lombok.extern.slf4j.Slf4j; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; - -import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; -import static bisq.apitest.config.BisqAppConfig.alicedaemon; -import static bisq.apitest.config.BisqAppConfig.seednode; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; - - - -import bisq.apitest.method.MethodTest; - -@Slf4j -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public class FundWalletScenarioTest extends MethodTest { - - @BeforeAll - public static void setUp() { - try { - setUpScaffold(bitcoind, seednode, alicedaemon); - bitcoinCli.generateBlocks(1); - MILLISECONDS.sleep(1500); - } catch (Exception ex) { - fail(ex); - } - } - - @Test - @Order(1) - public void testFundWallet() { - // bisq wallet was initialized with 10 btc - long balance = getBalance(alicedaemon); - assertEquals(1000000000, balance); - - String unusedAddress = getUnusedBtcAddress(alicedaemon); - bitcoinCli.sendToAddress(unusedAddress, "2.5"); - - bitcoinCli.generateBlocks(1); - sleep(1500); - - balance = getBalance(alicedaemon); - assertEquals(1250000000L, balance); // new balance is 12.5 btc - } - - @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 ecd38dc2295..0ef678f9bc9 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java +++ b/apitest/src/test/java/bisq/apitest/scenario/WalletTest.java @@ -24,55 +24,59 @@ import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestMethodOrder; import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind; import static bisq.apitest.config.BisqAppConfig.alicedaemon; +import static bisq.apitest.config.BisqAppConfig.arbdaemon; +import static bisq.apitest.config.BisqAppConfig.bobdaemon; import static bisq.apitest.config.BisqAppConfig.seednode; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; import bisq.apitest.method.MethodTest; -import bisq.apitest.method.WalletProtectionTest; +import bisq.apitest.method.wallet.BsqWalletTest; +import bisq.apitest.method.wallet.BtcWalletTest; +import bisq.apitest.method.wallet.WalletProtectionTest; @Slf4j @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class WalletTest extends MethodTest { - // All tests depend on the DAO / regtest environment, and Alice's wallet is - // initialized with 10 BTC during the scaffolding setup. - @BeforeAll public static void setUp() { - try { - setUpScaffold(bitcoind, seednode, alicedaemon); - genBtcBlocksThenWait(1, 1500); - } catch (Exception ex) { - fail(ex); - } + startSupportingApps(true, + true, + bitcoind, + seednode, + arbdaemon, + alicedaemon, + bobdaemon); } @Test @Order(1) - public void testFundWallet() { - // The regtest Bisq wallet was initialized with 10 BTC. - long balance = getBalance(alicedaemon); - assertEquals(1000000000, balance); + public void testBtcWalletFunding(final TestInfo testInfo) { + BtcWalletTest btcWalletTest = new BtcWalletTest(); - String unusedAddress = getUnusedBtcAddress(alicedaemon); - bitcoinCli.sendToAddress(unusedAddress, "2.5"); + btcWalletTest.testInitialBtcBalances(testInfo); + btcWalletTest.testFundAlicesBtcWallet(testInfo); + } - bitcoinCli.generateBlocks(1); - sleep(1500); + @Test + @Order(2) + public void testBsqWalletFunding(final TestInfo testInfo) { + BsqWalletTest bsqWalletTest = new BsqWalletTest(); - balance = getBalance(alicedaemon); - assertEquals(1250000000L, balance); // new balance is 12.5 btc + bsqWalletTest.testGetUnusedBsqAddress(); + bsqWalletTest.testInitialBsqBalances(testInfo); + bsqWalletTest.testSendBsqAndCheckBalancesBeforeGeneratingBtcBlock(testInfo); + bsqWalletTest.testBalancesAfterSendingBsqAndGeneratingBtcBlock(testInfo); } @Test - @Order(2) + @Order(3) public void testWalletProtection() { // Batching all wallet tests in this test case reduces scaffold setup // time. Here, we create a method WalletProtectionTest instance and run each diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index ec0e5e71bb6..efc03fc3100 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -23,17 +23,19 @@ import bisq.proto.grpc.CreateOfferRequest; import bisq.proto.grpc.CreatePaymentAccountRequest; import bisq.proto.grpc.GetAddressBalanceRequest; -import bisq.proto.grpc.GetBalanceRequest; +import bisq.proto.grpc.GetBalancesRequest; import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.GetOfferRequest; import bisq.proto.grpc.GetOffersRequest; import bisq.proto.grpc.GetPaymentAccountsRequest; import bisq.proto.grpc.GetTradeRequest; +import bisq.proto.grpc.GetUnusedBsqAddressRequest; import bisq.proto.grpc.GetVersionRequest; import bisq.proto.grpc.KeepFundsRequest; import bisq.proto.grpc.LockWalletRequest; import bisq.proto.grpc.RegisterDisputeAgentRequest; import bisq.proto.grpc.RemoveWalletPasswordRequest; +import bisq.proto.grpc.SendBsqRequest; import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.TakeOfferRequest; import bisq.proto.grpc.UnlockWalletRequest; @@ -53,10 +55,10 @@ import lombok.extern.slf4j.Slf4j; -import static bisq.cli.CurrencyFormat.formatSatoshis; import static bisq.cli.CurrencyFormat.toSatoshis; import static bisq.cli.NegativeNumberOptions.hasNegativeNumberOptions; import static bisq.cli.TableFormat.formatAddressBalanceTbl; +import static bisq.cli.TableFormat.formatBalancesTbls; import static bisq.cli.TableFormat.formatOfferTable; import static bisq.cli.TableFormat.formatPaymentAcctTbl; import static java.lang.String.format; @@ -90,6 +92,8 @@ private enum Method { getbalance, getaddressbalance, getfundingaddresses, + getunusedbsqaddress, + sendbsq, lockwallet, unlockwallet, removewalletpassword, @@ -183,10 +187,14 @@ public static void run(String[] args) { return; } case getbalance: { - var request = GetBalanceRequest.newBuilder().build(); - var reply = walletsService.getBalance(request); - var btcBalance = formatSatoshis(reply.getBalance()); - out.println(btcBalance); + var currencyCode = nonOptionArgs.size() == 2 + ? nonOptionArgs.get(1) + : ""; + var request = GetBalancesRequest.newBuilder() + .setCurrencyCode(currencyCode) + .build(); + var reply = walletsService.getBalances(request); + out.println(formatBalancesTbls(reply.getBalances())); return; } case getaddressbalance: { @@ -205,11 +213,42 @@ public static void run(String[] args) { out.println(formatAddressBalanceTbl(reply.getAddressBalanceInfoList())); return; } + case getunusedbsqaddress: { + var request = GetUnusedBsqAddressRequest.newBuilder().build(); + var reply = walletsService.getUnusedBsqAddress(request); + out.println(reply.getAddress()); + return; + } + case sendbsq: { + if (nonOptionArgs.size() < 2) + throw new IllegalArgumentException("no bsq address specified"); + + var address = nonOptionArgs.get(1); + + if (nonOptionArgs.size() < 3) + throw new IllegalArgumentException("no bsq amount specified"); + + double amount; + try { + amount = Double.parseDouble(nonOptionArgs.get(2)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(format("'%s' is not a number", nonOptionArgs.get(2))); + } + + var request = SendBsqRequest.newBuilder() + .setAddress(address) + .setAmount(amount) + .build(); + walletsService.sendBsq(request); + out.printf("%.2f BSQ sent to %s%n", amount, address); + return; + } case createoffer: { if (nonOptionArgs.size() < 9) throw new IllegalArgumentException("incorrect parameter count," + " expecting payment acct id, buy | sell, currency code, amount, min amount," - + " use-market-based-price, fixed-price | mkt-price-margin, security-deposit"); + + " use-market-based-price, fixed-price | mkt-price-margin, security-deposit" + + " [,maker-fee-currency-code = bsq|btc]"); var paymentAcctId = nonOptionArgs.get(1); var direction = nonOptionArgs.get(2); @@ -223,7 +262,11 @@ public static void run(String[] args) { marketPriceMargin = new BigDecimal(nonOptionArgs.get(7)); else fixedPrice = nonOptionArgs.get(7); + var securityDeposit = new BigDecimal(nonOptionArgs.get(8)); + var makerFeeCurrencyCode = nonOptionArgs.size() == 10 + ? nonOptionArgs.get(9) + : "btc"; var request = CreateOfferRequest.newBuilder() .setDirection(direction) @@ -235,6 +278,7 @@ public static void run(String[] args) { .setMarketPriceMargin(marketPriceMargin.doubleValue()) .setBuyerSecurityDeposit(securityDeposit.doubleValue()) .setPaymentAccountId(paymentAcctId) + .setMakerFeeCurrencyCode(makerFeeCurrencyCode) .build(); var reply = offersService.createOffer(request); out.println(formatOfferTable(singletonList(reply.getOffer()), currencyCode)); @@ -283,13 +327,19 @@ public static void run(String[] args) { } case takeoffer: { if (nonOptionArgs.size() < 3) - throw new IllegalArgumentException("incorrect parameter count, expecting offer id, payment acct id"); + throw new IllegalArgumentException("incorrect parameter count, " + + " expecting offer id, payment acct id [,taker fee currency code = bsq|btc]"); var offerId = nonOptionArgs.get(1); var paymentAccountId = nonOptionArgs.get(2); + var takerFeeCurrencyCode = nonOptionArgs.size() == 4 + ? nonOptionArgs.get(3) + : "btc"; + var request = TakeOfferRequest.newBuilder() .setOfferId(offerId) .setPaymentAccountId(paymentAccountId) + .setTakerFeeCurrencyCode(takerFeeCurrencyCode) .build(); var reply = tradesService.takeOffer(request); out.printf("trade '%s' successfully taken", reply.getTrade().getShortId()); @@ -297,7 +347,8 @@ public static void run(String[] args) { } case gettrade: { if (nonOptionArgs.size() < 2) - throw new IllegalArgumentException("incorrect parameter count, expecting trade id, [,showcontract = true|false]"); + throw new IllegalArgumentException("incorrect parameter count, " + + " expecting trade id [,showcontract = true|false]"); var tradeId = nonOptionArgs.get(1); var showContract = false; @@ -352,7 +403,8 @@ public static void run(String[] args) { } case withdrawfunds: { if (nonOptionArgs.size() < 3) - throw new IllegalArgumentException("incorrect parameter count, expecting trade id, bitcoin wallet address"); + throw new IllegalArgumentException("incorrect parameter count, " + + " expecting trade id, bitcoin wallet address"); var tradeId = nonOptionArgs.get(1); var address = nonOptionArgs.get(2); @@ -482,18 +534,20 @@ private static void printHelp(OptionParser parser, PrintStream stream) { stream.format(rowFormat, "Method", "Params", "Description"); stream.format(rowFormat, "------", "------", "------------"); stream.format(rowFormat, "getversion", "", "Get server version"); - stream.format(rowFormat, "getbalance", "", "Get server wallet balance"); + stream.format(rowFormat, "getbalance [,currency code = bsq|btc]", "", "Get server wallet balances"); 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, "createoffer", "payment acct id, buy | sell, currency code, \\", "Create and place an offer"); stream.format(rowFormat, "", "amount (btc), min amount, use mkt based price, \\", ""); - stream.format(rowFormat, "", "fixed price (btc) | mkt price margin (%), \\", ""); + stream.format(rowFormat, "", "fixed price (btc) | mkt price margin (%) [,maker fee currency code = bsq|btc]\\", ""); stream.format(rowFormat, "", "security deposit (%)", ""); stream.format(rowFormat, "canceloffer", "offer id", "Cancel offer with id"); stream.format(rowFormat, "getoffer", "offer id", "Get current offer with id"); stream.format(rowFormat, "getoffers", "buy | sell, currency code", "Get current offers"); - stream.format(rowFormat, "takeoffer", "offer id", "Take offer with id"); - stream.format(rowFormat, "gettrade", "trade id [,showcontract]", "Get trade summary or full contract"); + stream.format(rowFormat, "takeoffer", "offer id, [,taker fee currency code = bsq|btc]", "Take offer with id"); + stream.format(rowFormat, "gettrade", "trade id [,showcontract = true|false]", "Get trade summary or full contract"); stream.format(rowFormat, "confirmpaymentstarted", "trade id", "Confirm payment started"); stream.format(rowFormat, "confirmpaymentreceived", "trade id", "Confirm payment received"); stream.format(rowFormat, "keepfunds", "trade id", "Keep received funds in Bisq wallet"); diff --git a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java index 03500e4f47a..59b6230a2eb 100644 --- a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java +++ b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java @@ -29,9 +29,18 @@ class ColumnHeaderConstants { // such as COL_HEADER_CREATION_DATE, COL_HEADER_VOLUME and COL_HEADER_UUID, the // expected max data string length is accounted for. In others, the column header length // are expected to be greater than any column value length. - static final String COL_HEADER_ADDRESS = padEnd("Address", 34, ' '); + 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_BALANCE = padStart("Balance", 12, ' '); + 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"; + static final String COL_HEADER_RESERVED_BALANCE = "Reserved Balance"; + static final String COL_HEADER_TOTAL_AVAILABLE_BALANCE = "Total Available Balance"; + static final String COL_HEADER_LOCKED_BALANCE = "Locked Balance"; + static final String COL_HEADER_LOCKED_FOR_VOTING_BALANCE = "Locked For Voting Balance"; + static final String COL_HEADER_LOCKUP_BONDS_BALANCE = "Lockup Bonds Balance"; + static final String COL_HEADER_UNLOCKING_BONDS_BALANCE = "Unlocking Bonds Balance"; + static final String COL_HEADER_UNVERIFIED_BALANCE = "Unverified Balance"; static final String COL_HEADER_CONFIRMATIONS = "Confirmations"; static final String COL_HEADER_CREATION_DATE = padEnd("Creation Date (UTC)", 20, ' '); static final String COL_HEADER_CURRENCY = "Currency"; diff --git a/cli/src/main/java/bisq/cli/CurrencyFormat.java b/cli/src/main/java/bisq/cli/CurrencyFormat.java index e4d8f89c6c7..a4766690eff 100644 --- a/cli/src/main/java/bisq/cli/CurrencyFormat.java +++ b/cli/src/main/java/bisq/cli/CurrencyFormat.java @@ -37,12 +37,19 @@ public class CurrencyFormat { static final BigDecimal SATOSHI_DIVISOR = new BigDecimal(100000000); static final DecimalFormat BTC_FORMAT = new DecimalFormat("###,##0.00000000"); - @VisibleForTesting + static final BigDecimal BSQ_SATOSHI_DIVISOR = new BigDecimal(100); + static final DecimalFormat BSQ_FORMAT = new DecimalFormat("###,###,###,##0.00"); + @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled") public static String formatSatoshis(long sats) { return BTC_FORMAT.format(BigDecimal.valueOf(sats).divide(SATOSHI_DIVISOR)); } + @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled") + public static String formatBsq(long sats) { + return BSQ_FORMAT.format(BigDecimal.valueOf(sats).divide(BSQ_SATOSHI_DIVISOR)); + } + static String formatAmountRange(long minAmount, long amount) { return minAmount != amount ? formatSatoshis(minAmount) + " - " + formatSatoshis(amount) diff --git a/cli/src/main/java/bisq/cli/TableFormat.java b/cli/src/main/java/bisq/cli/TableFormat.java index 8336fff9ba1..a23b7a022d8 100644 --- a/cli/src/main/java/bisq/cli/TableFormat.java +++ b/cli/src/main/java/bisq/cli/TableFormat.java @@ -18,10 +18,15 @@ package bisq.cli; import bisq.proto.grpc.AddressBalanceInfo; +import bisq.proto.grpc.BalancesInfo; +import bisq.proto.grpc.BsqBalanceInfo; +import bisq.proto.grpc.BtcBalanceInfo; import bisq.proto.grpc.OfferInfo; import protobuf.PaymentAccount; +import com.google.common.annotations.VisibleForTesting; + import java.text.SimpleDateFormat; import java.util.Date; @@ -30,28 +35,28 @@ import java.util.stream.Collectors; import static bisq.cli.ColumnHeaderConstants.*; -import static bisq.cli.CurrencyFormat.formatAmountRange; -import static bisq.cli.CurrencyFormat.formatOfferPrice; -import static bisq.cli.CurrencyFormat.formatSatoshis; -import static bisq.cli.CurrencyFormat.formatVolumeRange; +import static bisq.cli.CurrencyFormat.*; import static com.google.common.base.Strings.padEnd; import static java.lang.String.format; import static java.util.Collections.max; import static java.util.Comparator.comparing; import static java.util.TimeZone.getTimeZone; -class TableFormat { +@VisibleForTesting +public class TableFormat { static final TimeZone TZ_UTC = getTimeZone("UTC"); static final SimpleDateFormat DATE_FORMAT_ISO_8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); - static String formatAddressBalanceTbl(List addressBalanceInfo) { - String headerLine = (COL_HEADER_ADDRESS + COL_HEADER_DELIMITER - + COL_HEADER_BALANCE + COL_HEADER_DELIMITER - + COL_HEADER_CONFIRMATIONS + COL_HEADER_DELIMITER + "\n"); - String colDataFormat = "%-" + COL_HEADER_ADDRESS.length() + "s" // left justify - + " %" + COL_HEADER_BALANCE.length() + "s" // right justify - + " %" + COL_HEADER_CONFIRMATIONS.length() + "d"; // right justify + public static String formatAddressBalanceTbl(List addressBalanceInfo) { + String headerFormatString = COL_HEADER_ADDRESS + COL_HEADER_DELIMITER + + COL_HEADER_AVAILABLE_BALANCE + COL_HEADER_DELIMITER + + COL_HEADER_CONFIRMATIONS + COL_HEADER_DELIMITER + "\n"; + String headerLine = format(headerFormatString, "BTC"); + + String colDataFormat = "%-" + COL_HEADER_ADDRESS.length() + "s" // lt justify + + " %" + (COL_HEADER_AVAILABLE_BALANCE.length() - 1) + "s" // rt justify + + " %" + COL_HEADER_CONFIRMATIONS.length() + "d"; // lt justify return headerLine + addressBalanceInfo.stream() .map(info -> format(colDataFormat, @@ -61,15 +66,58 @@ static String formatAddressBalanceTbl(List addressBalanceInf .collect(Collectors.joining("\n")); } - static String formatOfferTable(List offerInfo, String fiatCurrency) { + public static String formatBalancesTbls(BalancesInfo balancesInfo) { + return "BTC" + "\n" + + formatBtcBalanceInfoTbl(balancesInfo.getBtc()) + "\n" + + "BSQ" + "\n" + + formatBsqBalanceInfoTbl(balancesInfo.getBsq()); + } + + public static String formatBsqBalanceInfoTbl(BsqBalanceInfo bsqBalanceInfo) { + String headerLine = COL_HEADER_AVAILABLE_CONFIRMED_BALANCE + COL_HEADER_DELIMITER + + COL_HEADER_UNVERIFIED_BALANCE + COL_HEADER_DELIMITER + + COL_HEADER_UNCONFIRMED_CHANGE_BALANCE + COL_HEADER_DELIMITER + + COL_HEADER_LOCKED_FOR_VOTING_BALANCE + COL_HEADER_DELIMITER + + COL_HEADER_LOCKUP_BONDS_BALANCE + COL_HEADER_DELIMITER + + COL_HEADER_UNLOCKING_BONDS_BALANCE + COL_HEADER_DELIMITER + "\n"; + String colDataFormat = "%" + COL_HEADER_AVAILABLE_CONFIRMED_BALANCE.length() + "s" // rt justify + + " %" + (COL_HEADER_UNVERIFIED_BALANCE.length() + 1) + "s" // rt justify + + " %" + (COL_HEADER_UNCONFIRMED_CHANGE_BALANCE.length() + 1) + "s" // rt justify + + " %" + (COL_HEADER_LOCKED_FOR_VOTING_BALANCE.length() + 1) + "s" // rt justify + + " %" + (COL_HEADER_LOCKUP_BONDS_BALANCE.length() + 1) + "s" // rt justify + + " %" + (COL_HEADER_UNLOCKING_BONDS_BALANCE.length() + 1) + "s"; // rt justify + return headerLine + format(colDataFormat, + formatBsq(bsqBalanceInfo.getAvailableConfirmedBalance()), + formatBsq(bsqBalanceInfo.getUnverifiedBalance()), + formatBsq(bsqBalanceInfo.getUnconfirmedChangeBalance()), + formatBsq(bsqBalanceInfo.getLockedForVotingBalance()), + formatBsq(bsqBalanceInfo.getLockupBondsBalance()), + formatBsq(bsqBalanceInfo.getUnlockingBondsBalance())); + } + + public static String formatBtcBalanceInfoTbl(BtcBalanceInfo btcBalanceInfo) { + String headerLine = COL_HEADER_AVAILABLE_BALANCE + COL_HEADER_DELIMITER + + COL_HEADER_RESERVED_BALANCE + COL_HEADER_DELIMITER + + COL_HEADER_TOTAL_AVAILABLE_BALANCE + COL_HEADER_DELIMITER + + COL_HEADER_LOCKED_BALANCE + COL_HEADER_DELIMITER + "\n"; + String colDataFormat = "%" + COL_HEADER_AVAILABLE_BALANCE.length() + "s" // rt justify + + " %" + (COL_HEADER_RESERVED_BALANCE.length() + 1) + "s" // rt justify + + " %" + (COL_HEADER_TOTAL_AVAILABLE_BALANCE.length() + 1) + "s" // rt justify + + " %" + (COL_HEADER_LOCKED_BALANCE.length() + 1) + "s"; // rt justify + return headerLine + format(colDataFormat, + formatSatoshis(btcBalanceInfo.getAvailableBalance()), + formatSatoshis(btcBalanceInfo.getReservedBalance()), + formatSatoshis(btcBalanceInfo.getTotalAvailableBalance()), + formatSatoshis(btcBalanceInfo.getLockedBalance())); + } + static String formatOfferTable(List offerInfo, String fiatCurrency) { // Some column values might be longer than header, so we need to calculate them. int paymentMethodColWidth = getLengthOfLongestColumn( COL_HEADER_PAYMENT_METHOD.length(), offerInfo.stream() .map(OfferInfo::getPaymentMethodShortName) .collect(Collectors.toList())); - String headersFormat = COL_HEADER_DIRECTION + COL_HEADER_DELIMITER + COL_HEADER_PRICE + COL_HEADER_DELIMITER // includes %s -> fiatCurrency + COL_HEADER_AMOUNT + COL_HEADER_DELIMITER diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java index 7cfcc5ce152..cd5ce5b53f5 100644 --- a/core/src/main/java/bisq/core/api/CoreApi.java +++ b/core/src/main/java/bisq/core/api/CoreApi.java @@ -18,6 +18,8 @@ package bisq.core.api; import bisq.core.api.model.AddressBalanceInfo; +import bisq.core.api.model.BalancesInfo; +import bisq.core.btc.wallet.TxBroadcaster; import bisq.core.monetary.Price; import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; @@ -107,6 +109,7 @@ public void createAnPlaceOffer(String currencyCode, long minAmountAsLong, double buyerSecurityDeposit, String paymentAccountId, + String makerFeeCurrencyCode, Consumer resultHandler) { coreOffersService.createAndPlaceOffer(currencyCode, directionAsString, @@ -117,6 +120,7 @@ public void createAnPlaceOffer(String currencyCode, minAmountAsLong, buyerSecurityDeposit, paymentAccountId, + makerFeeCurrencyCode, resultHandler); } @@ -178,10 +182,12 @@ public double getMarketPrice(String currencyCode) { public void takeOffer(String offerId, String paymentAccountId, + String takerFeeCurrencyCode, Consumer resultHandler) { Offer offer = coreOffersService.getOffer(offerId); coreTradesService.takeOffer(offer, paymentAccountId, + takerFeeCurrencyCode, resultHandler); } @@ -213,8 +219,8 @@ public String getTradeRole(String tradeId) { // Wallets /////////////////////////////////////////////////////////////////////////////////////////// - public long getAvailableBalance() { - return walletsService.getAvailableBalance(); + public BalancesInfo getBalances(String currencyCode) { + return walletsService.getBalances(currencyCode); } public long getAddressBalance(String addressString) { @@ -229,6 +235,14 @@ public List getFundingAddresses() { return walletsService.getFundingAddresses(); } + public String getUnusedBsqAddress() { + return walletsService.getUnusedBsqAddress(); + } + + public void sendBsq(String address, double amount, TxBroadcaster.Callback callback) { + walletsService.sendBsq(address, amount, callback); + } + public void setWalletPassword(String password, String newPassword) { walletsService.setWalletPassword(password, newPassword); } diff --git a/core/src/main/java/bisq/core/api/CoreOffersService.java b/core/src/main/java/bisq/core/api/CoreOffersService.java index 6d8641c0579..bca9dc7cbf9 100644 --- a/core/src/main/java/bisq/core/api/CoreOffersService.java +++ b/core/src/main/java/bisq/core/api/CoreOffersService.java @@ -22,6 +22,7 @@ import bisq.core.offer.CreateOfferService; import bisq.core.offer.Offer; import bisq.core.offer.OfferBookService; +import bisq.core.offer.OfferUtil; import bisq.core.offer.OpenOfferManager; import bisq.core.payment.PaymentAccount; import bisq.core.user.User; @@ -55,16 +56,19 @@ class CoreOffersService { private final CreateOfferService createOfferService; private final OfferBookService offerBookService; private final OpenOfferManager openOfferManager; + private final OfferUtil offerUtil; private final User user; @Inject public CoreOffersService(CreateOfferService createOfferService, OfferBookService offerBookService, OpenOfferManager openOfferManager, + OfferUtil offerUtil, User user) { this.createOfferService = createOfferService; this.offerBookService = offerBookService; this.openOfferManager = openOfferManager; + this.offerUtil = offerUtil; this.user = user; } @@ -105,7 +109,11 @@ void createAndPlaceOffer(String currencyCode, long minAmountAsLong, double buyerSecurityDeposit, String paymentAccountId, + String makerFeeCurrencyCode, Consumer resultHandler) { + + offerUtil.maybeSetFeePaymentCurrencyPreference(makerFeeCurrencyCode); + String upperCaseCurrencyCode = currencyCode.toUpperCase(); String offerId = createOfferService.getRandomOfferId(); Direction direction = Direction.valueOf(directionAsString.toUpperCase()); diff --git a/core/src/main/java/bisq/core/api/CoreTradesService.java b/core/src/main/java/bisq/core/api/CoreTradesService.java index dbc6927f452..4bc678d9263 100644 --- a/core/src/main/java/bisq/core/api/CoreTradesService.java +++ b/core/src/main/java/bisq/core/api/CoreTradesService.java @@ -20,6 +20,7 @@ import bisq.core.btc.model.AddressEntry; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.offer.Offer; +import bisq.core.offer.OfferUtil; import bisq.core.offer.takeoffer.TakeOfferModel; import bisq.core.trade.Tradable; import bisq.core.trade.Trade; @@ -52,6 +53,7 @@ class CoreTradesService { private final CoreWalletsService coreWalletsService; private final BtcWalletService btcWalletService; + private final OfferUtil offerUtil; private final ClosedTradableManager closedTradableManager; private final TakeOfferModel takeOfferModel; private final TradeManager tradeManager; @@ -61,6 +63,7 @@ class CoreTradesService { @Inject public CoreTradesService(CoreWalletsService coreWalletsService, BtcWalletService btcWalletService, + OfferUtil offerUtil, ClosedTradableManager closedTradableManager, TakeOfferModel takeOfferModel, TradeManager tradeManager, @@ -68,6 +71,7 @@ public CoreTradesService(CoreWalletsService coreWalletsService, User user) { this.coreWalletsService = coreWalletsService; this.btcWalletService = btcWalletService; + this.offerUtil = offerUtil; this.closedTradableManager = closedTradableManager; this.takeOfferModel = takeOfferModel; this.tradeManager = tradeManager; @@ -77,7 +81,11 @@ public CoreTradesService(CoreWalletsService coreWalletsService, void takeOffer(Offer offer, String paymentAccountId, + String takerFeeCurrencyCode, Consumer resultHandler) { + + offerUtil.maybeSetFeePaymentCurrencyPreference(takerFeeCurrencyCode); + var paymentAccount = user.getPaymentAccount(paymentAccountId); if (paymentAccount == null) throw new IllegalArgumentException(format("payment account with id '%s' not found", paymentAccountId)); diff --git a/core/src/main/java/bisq/core/api/CoreWalletsService.java b/core/src/main/java/bisq/core/api/CoreWalletsService.java index fc15ec5062a..529b97a17f6 100644 --- a/core/src/main/java/bisq/core/api/CoreWalletsService.java +++ b/core/src/main/java/bisq/core/api/CoreWalletsService.java @@ -18,15 +18,29 @@ package bisq.core.api; import bisq.core.api.model.AddressBalanceInfo; +import bisq.core.api.model.BalancesInfo; +import bisq.core.api.model.BsqBalanceInfo; +import bisq.core.api.model.BtcBalanceInfo; import bisq.core.btc.Balances; +import bisq.core.btc.exceptions.BsqChangeBelowDustException; +import bisq.core.btc.exceptions.TransactionVerificationException; +import bisq.core.btc.exceptions.WalletException; import bisq.core.btc.model.AddressEntry; +import bisq.core.btc.model.BsqTransferModel; +import bisq.core.btc.wallet.BsqTransferService; +import bisq.core.btc.wallet.BsqWalletService; import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.btc.wallet.TxBroadcaster; import bisq.core.btc.wallet.WalletsManager; +import bisq.core.util.coin.BsqFormatter; import bisq.common.Timer; import bisq.common.UserThread; import org.bitcoinj.core.Address; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.InsufficientMoneyException; +import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.core.TransactionConfidence; import org.bitcoinj.crypto.KeyCrypterScrypt; @@ -47,6 +61,7 @@ import javax.annotation.Nullable; +import static bisq.core.util.ParsingUtils.parseToCoin; import static java.lang.String.format; import static java.util.concurrent.TimeUnit.SECONDS; @@ -55,6 +70,9 @@ class CoreWalletsService { private final Balances balances; private final WalletsManager walletsManager; + private final BsqWalletService bsqWalletService; + private final BsqTransferService bsqTransferService; + private final BsqFormatter bsqFormatter; private final BtcWalletService btcWalletService; @Nullable @@ -66,9 +84,15 @@ class CoreWalletsService { @Inject public CoreWalletsService(Balances balances, WalletsManager walletsManager, + BsqWalletService bsqWalletService, + BsqTransferService bsqTransferService, + BsqFormatter bsqFormatter, BtcWalletService btcWalletService) { this.balances = balances; this.walletsManager = walletsManager; + this.bsqWalletService = bsqWalletService; + this.bsqTransferService = bsqTransferService; + this.bsqFormatter = bsqFormatter; this.btcWalletService = btcWalletService; } @@ -78,6 +102,7 @@ KeyParameter getKey() { return tempAesKey; } + @Deprecated long getAvailableBalance() { verifyWalletsAreAvailable(); verifyEncryptedWalletIsUnlocked(); @@ -89,6 +114,23 @@ long getAvailableBalance() { return balance.getValue(); } + BalancesInfo getBalances(String currencyCode) { + verifyWalletCurrencyCodeIsValid(currencyCode); + verifyWalletsAreAvailable(); + verifyEncryptedWalletIsUnlocked(); + if (balances.getAvailableBalance().get() == null) + throw new IllegalStateException("balance is not yet available"); + + switch (currencyCode.trim().toUpperCase()) { + case "BSQ": + return new BalancesInfo(getBsqBalances(), BtcBalanceInfo.EMPTY); + case "BTC": + return new BalancesInfo(BsqBalanceInfo.EMPTY, getBtcBalances()); + default: + return new BalancesInfo(getBsqBalances(), getBtcBalances()); + } + } + long getAddressBalance(String addressString) { Address address = getAddressEntry(addressString).getAddress(); return btcWalletService.getBalanceForAddress(address).value; @@ -134,6 +176,27 @@ List getFundingAddresses() { .collect(Collectors.toList()); } + String getUnusedBsqAddress() { + return bsqWalletService.getUnusedBsqAddressAsString(); + } + + void sendBsq(String address, + double amount, + TxBroadcaster.Callback callback) { + try { + LegacyAddress legacyAddress = getValidBsqLegacyAddress(address); + Coin receiverAmount = getValidBsqTransferAmount(amount); + BsqTransferModel model = bsqTransferService.getBsqTransferModel(legacyAddress, receiverAmount); + bsqTransferService.sendFunds(model, callback); + } catch (InsufficientMoneyException + | BsqChangeBelowDustException + | TransactionVerificationException + | WalletException ex) { + log.error("", ex); + throw new IllegalStateException(ex); + } + } + int getNumConfirmationsForMostRecentTransaction(String addressString) { Address address = getAddressEntry(addressString).getAddress(); TransactionConfidence confidence = btcWalletService.getConfidenceForAddress(address); @@ -244,6 +307,76 @@ private void verifyEncryptedWalletIsUnlocked() { throw new IllegalStateException("wallet is locked"); } + // Throws a RuntimeException if wallet currency code is not BSQ or BTC. + private void verifyWalletCurrencyCodeIsValid(String currencyCode) { + if (currencyCode == null || currencyCode.isEmpty()) + return; + + if (!currencyCode.equalsIgnoreCase("BSQ") + && !currencyCode.equalsIgnoreCase("BTC")) + throw new IllegalStateException(format("wallet does not support %s", currencyCode)); + } + + private BsqBalanceInfo getBsqBalances() { + verifyWalletsAreAvailable(); + verifyEncryptedWalletIsUnlocked(); + + var availableConfirmedBalance = bsqWalletService.getAvailableConfirmedBalance(); + var unverifiedBalance = bsqWalletService.getUnverifiedBalance(); + var unconfirmedChangeBalance = bsqWalletService.getUnconfirmedChangeBalance(); + var lockedForVotingBalance = bsqWalletService.getLockedForVotingBalance(); + var lockupBondsBalance = bsqWalletService.getLockupBondsBalance(); + var unlockingBondsBalance = bsqWalletService.getUnlockingBondsBalance(); + + return new BsqBalanceInfo(availableConfirmedBalance.value, + unverifiedBalance.value, + unconfirmedChangeBalance.value, + lockedForVotingBalance.value, + lockupBondsBalance.value, + unlockingBondsBalance.value); + } + + private BtcBalanceInfo getBtcBalances() { + verifyWalletsAreAvailable(); + verifyEncryptedWalletIsUnlocked(); + + var availableBalance = balances.getAvailableBalance().get(); + if (availableBalance == null) + throw new IllegalStateException("balance is not yet available"); + + var reservedBalance = balances.getReservedBalance().get(); + if (reservedBalance == null) + throw new IllegalStateException("reserved balance is not yet available"); + + var lockedBalance = balances.getLockedBalance().get(); + if (lockedBalance == null) + throw new IllegalStateException("locked balance is not yet available"); + + return new BtcBalanceInfo(availableBalance.value, + reservedBalance.value, + availableBalance.add(reservedBalance).value, + 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 double amount, or a RuntimeException if invalid. + private Coin getValidBsqTransferAmount(double amount) { + Coin amountAsCoin = parseToCoin(Double.toString(amount), bsqFormatter); + if (amountAsCoin.equals(Coin.ZERO)) + throw new IllegalStateException(format("%.2f bsq is an invalid send amount", amount)); + + return amountAsCoin; + } + private KeyCrypterScrypt getKeyCrypterScrypt() { KeyCrypterScrypt keyCrypterScrypt = walletsManager.getKeyCrypterScrypt(); if (keyCrypterScrypt == null) diff --git a/core/src/main/java/bisq/core/api/model/BalancesInfo.java b/core/src/main/java/bisq/core/api/model/BalancesInfo.java new file mode 100644 index 00000000000..3b063bc0d2b --- /dev/null +++ b/core/src/main/java/bisq/core/api/model/BalancesInfo.java @@ -0,0 +1,45 @@ +package bisq.core.api.model; + +import bisq.common.Payload; + +import lombok.Getter; + +@Getter +public class BalancesInfo implements Payload { + + // Getter names are shortened for readability's sake, i.e., + // balancesInfo.getBtc().getAvailableBalance() is cleaner than + // balancesInfo.getBtcBalanceInfo().getAvailableBalance(). + private final BsqBalanceInfo bsq; + private final BtcBalanceInfo btc; + + public BalancesInfo(BsqBalanceInfo bsq, BtcBalanceInfo btc) { + this.bsq = bsq; + this.btc = btc; + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public bisq.proto.grpc.BalancesInfo toProtoMessage() { + return bisq.proto.grpc.BalancesInfo.newBuilder() + .setBsq(bsq.toProtoMessage()) + .setBtc(btc.toProtoMessage()) + .build(); + } + + public static BalancesInfo fromProto(bisq.proto.grpc.BalancesInfo proto) { + return new BalancesInfo(BsqBalanceInfo.fromProto(proto.getBsq()), + BtcBalanceInfo.fromProto(proto.getBtc())); + } + + @Override + public String toString() { + return "BalancesInfo{" + "\n" + + " " + bsq.toString() + "\n" + + ", " + btc.toString() + "\n" + + '}'; + } +} diff --git a/core/src/main/java/bisq/core/api/model/BsqBalanceInfo.java b/core/src/main/java/bisq/core/api/model/BsqBalanceInfo.java new file mode 100644 index 00000000000..23324e21f33 --- /dev/null +++ b/core/src/main/java/bisq/core/api/model/BsqBalanceInfo.java @@ -0,0 +1,94 @@ +package bisq.core.api.model; + +import bisq.common.Payload; + +import com.google.common.annotations.VisibleForTesting; + +import lombok.Getter; + +@Getter +public class BsqBalanceInfo implements Payload { + + public static final BsqBalanceInfo EMPTY = new BsqBalanceInfo(-1, + -1, + -1, + -1, + -1, + -1); + + // All balances are in BSQ satoshis. + private final long availableConfirmedBalance; + private final long unverifiedBalance; + private final long unconfirmedChangeBalance; + private final long lockedForVotingBalance; + private final long lockupBondsBalance; + private final long unlockingBondsBalance; + + public BsqBalanceInfo(long availableConfirmedBalance, + long unverifiedBalance, + long unconfirmedChangeBalance, + long lockedForVotingBalance, + long lockupBondsBalance, + long unlockingBondsBalance) { + this.availableConfirmedBalance = availableConfirmedBalance; + this.unverifiedBalance = unverifiedBalance; + this.unconfirmedChangeBalance = unconfirmedChangeBalance; + this.lockedForVotingBalance = lockedForVotingBalance; + this.lockupBondsBalance = lockupBondsBalance; + this.unlockingBondsBalance = unlockingBondsBalance; + } + + @VisibleForTesting + public static BsqBalanceInfo valueOf(long availableConfirmedBalance, + long unverifiedBalance, + long unconfirmedChangeBalance, + long lockedForVotingBalance, + long lockupBondsBalance, + long unlockingBondsBalance) { + // Convenience for creating a model instance instead of a proto. + return new BsqBalanceInfo(availableConfirmedBalance, + unverifiedBalance, + unconfirmedChangeBalance, + lockedForVotingBalance, + lockupBondsBalance, + unlockingBondsBalance); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public bisq.proto.grpc.BsqBalanceInfo toProtoMessage() { + return bisq.proto.grpc.BsqBalanceInfo.newBuilder() + .setAvailableConfirmedBalance(availableConfirmedBalance) + .setUnverifiedBalance(unverifiedBalance) + .setUnconfirmedChangeBalance(unconfirmedChangeBalance) + .setLockedForVotingBalance(lockedForVotingBalance) + .setLockupBondsBalance(lockupBondsBalance) + .setUnlockingBondsBalance(unlockingBondsBalance) + .build(); + + } + + public static BsqBalanceInfo fromProto(bisq.proto.grpc.BsqBalanceInfo proto) { + return new BsqBalanceInfo(proto.getAvailableConfirmedBalance(), + proto.getUnverifiedBalance(), + proto.getUnconfirmedChangeBalance(), + proto.getLockedForVotingBalance(), + proto.getLockupBondsBalance(), + proto.getUnlockingBondsBalance()); + } + + @Override + public String toString() { + return "BsqBalanceInfo{" + + "availableConfirmedBalance=" + availableConfirmedBalance + + ", unverifiedBalance=" + unverifiedBalance + + ", unconfirmedChangeBalance=" + unconfirmedChangeBalance + + ", lockedForVotingBalance=" + lockedForVotingBalance + + ", lockupBondsBalance=" + lockupBondsBalance + + ", unlockingBondsBalance=" + unlockingBondsBalance + + '}'; + } +} diff --git a/core/src/main/java/bisq/core/api/model/BtcBalanceInfo.java b/core/src/main/java/bisq/core/api/model/BtcBalanceInfo.java new file mode 100644 index 00000000000..e3803b0001e --- /dev/null +++ b/core/src/main/java/bisq/core/api/model/BtcBalanceInfo.java @@ -0,0 +1,75 @@ +package bisq.core.api.model; + +import bisq.common.Payload; + +import com.google.common.annotations.VisibleForTesting; + +import lombok.Getter; + +@Getter +public class BtcBalanceInfo implements Payload { + + public static final BtcBalanceInfo EMPTY = new BtcBalanceInfo(-1, + -1, + -1, + -1); + + // All balances are in BTC satoshis. + private final long availableBalance; + private final long reservedBalance; + private final long totalAvailableBalance; // available + reserved + private final long lockedBalance; + + public BtcBalanceInfo(long availableBalance, + long reservedBalance, + long totalAvailableBalance, + long lockedBalance) { + this.availableBalance = availableBalance; + this.reservedBalance = reservedBalance; + this.totalAvailableBalance = totalAvailableBalance; + this.lockedBalance = lockedBalance; + } + + @VisibleForTesting + public static BtcBalanceInfo valueOf(long availableBalance, + long reservedBalance, + long totalAvailableBalance, + long lockedBalance) { + // Convenience for creating a model instance instead of a proto. + return new BtcBalanceInfo(availableBalance, + reservedBalance, + totalAvailableBalance, + lockedBalance); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public bisq.proto.grpc.BtcBalanceInfo toProtoMessage() { + return bisq.proto.grpc.BtcBalanceInfo.newBuilder() + .setAvailableBalance(availableBalance) + .setReservedBalance(reservedBalance) + .setTotalAvailableBalance(totalAvailableBalance) + .setLockedBalance(lockedBalance) + .build(); + } + + public static BtcBalanceInfo fromProto(bisq.proto.grpc.BtcBalanceInfo proto) { + return new BtcBalanceInfo(proto.getAvailableBalance(), + proto.getReservedBalance(), + proto.getTotalAvailableBalance(), + proto.getLockedBalance()); + } + + @Override + public String toString() { + return "BtcBalanceInfo{" + + "availableBalance=" + availableBalance + + ", reservedBalance=" + reservedBalance + + ", totalAvailableBalance=" + totalAvailableBalance + + ", lockedBalance=" + lockedBalance + + '}'; + } +} 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 219045b2762..cc11aacb1d9 100644 --- a/core/src/main/java/bisq/core/api/model/OfferInfo.java +++ b/core/src/main/java/bisq/core/api/model/OfferInfo.java @@ -46,6 +46,7 @@ public class OfferInfo implements Payload { private final long volume; private final long minVolume; private final long buyerSecurityDeposit; + private final boolean isCurrencyForMakerFeeBtc; private final String paymentAccountId; // only used when creating offer private final String paymentMethodId; private final String paymentMethodShortName; @@ -67,6 +68,7 @@ public OfferInfo(OfferInfoBuilder builder) { this.volume = builder.volume; this.minVolume = builder.minVolume; this.buyerSecurityDeposit = builder.buyerSecurityDeposit; + this.isCurrencyForMakerFeeBtc = builder.isCurrencyForMakerFeeBtc; this.paymentAccountId = builder.paymentAccountId; this.paymentMethodId = builder.paymentMethodId; this.paymentMethodShortName = builder.paymentMethodShortName; @@ -88,6 +90,7 @@ public static OfferInfo toOfferInfo(Offer offer) { .withVolume(Objects.requireNonNull(offer.getVolume()).getValue()) .withMinVolume(Objects.requireNonNull(offer.getMinVolume()).getValue()) .withBuyerSecurityDeposit(offer.getBuyerSecurityDeposit().value) + .withIsCurrencyForMakerFeeBtc(offer.isCurrencyForMakerFeeBtc()) .withPaymentAccountId(offer.getMakerPaymentAccountId()) .withPaymentMethodId(offer.getPaymentMethod().getId()) .withPaymentMethodShortName(offer.getPaymentMethod().getShortName()) @@ -115,6 +118,7 @@ public bisq.proto.grpc.OfferInfo toProtoMessage() { .setVolume(volume) .setMinVolume(minVolume) .setBuyerSecurityDeposit(buyerSecurityDeposit) + .setIsCurrencyForMakerFeeBtc(isCurrencyForMakerFeeBtc) .setPaymentAccountId(paymentAccountId) .setPaymentMethodId(paymentMethodId) .setPaymentMethodShortName(paymentMethodShortName) @@ -147,6 +151,7 @@ public static class OfferInfoBuilder { private long volume; private long minVolume; private long buyerSecurityDeposit; + private boolean isCurrencyForMakerFeeBtc; private String paymentAccountId; private String paymentMethodId; private String paymentMethodShortName; @@ -205,6 +210,11 @@ public OfferInfoBuilder withBuyerSecurityDeposit(long buyerSecurityDeposit) { return this; } + public OfferInfoBuilder withIsCurrencyForMakerFeeBtc(boolean isCurrencyForMakerFeeBtc) { + this.isCurrencyForMakerFeeBtc = isCurrencyForMakerFeeBtc; + return this; + } + public OfferInfoBuilder withPaymentAccountId(String paymentAccountId) { this.paymentAccountId = paymentAccountId; return this; diff --git a/core/src/main/java/bisq/core/btc/model/BsqTransferModel.java b/core/src/main/java/bisq/core/btc/model/BsqTransferModel.java new file mode 100644 index 00000000000..38e4c4b5423 --- /dev/null +++ b/core/src/main/java/bisq/core/btc/model/BsqTransferModel.java @@ -0,0 +1,77 @@ +package bisq.core.btc.model; + +import bisq.core.dao.state.model.blockchain.TxType; +import bisq.core.util.coin.CoinUtil; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.Transaction; + +import lombok.Getter; + +@Getter +public final class BsqTransferModel { + + private final LegacyAddress receiverAddress; + private final Coin receiverAmount; + private final Transaction preparedSendTx; + private final Transaction txWithBtcFee; + private final Transaction signedTx; + private final Coin miningFee; + private final int txSize; + private final TxType txType; + + public BsqTransferModel(LegacyAddress receiverAddress, + Coin receiverAmount, + Transaction preparedSendTx, + Transaction txWithBtcFee, + Transaction signedTx) { + this.receiverAddress = receiverAddress; + this.receiverAmount = receiverAmount; + this.preparedSendTx = preparedSendTx; + this.txWithBtcFee = txWithBtcFee; + this.signedTx = signedTx; + this.miningFee = signedTx.getFee(); + this.txSize = signedTx.bitcoinSerialize().length; + this.txType = TxType.TRANSFER_BSQ; + } + + public String getReceiverAddressAsString() { + return receiverAddress.toString(); + } + + public double getMiningFeeInSatoshisPerByte() { + return CoinUtil.getFeePerVbyte(miningFee, txSize); + } + + public double getTxSizeInKb() { + return txSize / 1000d; + } + + public String toShortString() { + return "{" + "\n" + + " receiverAddress='" + getReceiverAddressAsString() + '\'' + "\n" + + ", receiverAmount=" + receiverAmount + "\n" + + ", txWithBtcFee.txId=" + txWithBtcFee.getTxId() + "\n" + + ", miningFee=" + miningFee + "\n" + + ", miningFeeInSatoshisPerByte=" + getMiningFeeInSatoshisPerByte() + "\n" + + ", txSizeInKb=" + getTxSizeInKb() + "\n" + + '}'; + } + + @Override + public String toString() { + return "BsqTransferModel{" + "\n" + + " receiverAddress='" + getReceiverAddressAsString() + '\'' + "\n" + + ", receiverAmount=" + receiverAmount + "\n" + + ", preparedSendTx=" + preparedSendTx + "\n" + + ", txWithBtcFee=" + txWithBtcFee + "\n" + + ", signedTx=" + signedTx + "\n" + + ", miningFee=" + miningFee + "\n" + + ", miningFeeInSatoshisPerByte=" + getMiningFeeInSatoshisPerByte() + "\n" + + ", txSize=" + txSize + "\n" + + ", txSizeInKb=" + getTxSizeInKb() + "\n" + + ", txType=" + txType + "\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 new file mode 100644 index 00000000000..b6cc83e8c77 --- /dev/null +++ b/core/src/main/java/bisq/core/btc/wallet/BsqTransferService.java @@ -0,0 +1,59 @@ +package bisq.core.btc.wallet; + +import bisq.core.btc.exceptions.BsqChangeBelowDustException; +import bisq.core.btc.exceptions.TransactionVerificationException; +import bisq.core.btc.exceptions.WalletException; +import bisq.core.btc.model.BsqTransferModel; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.InsufficientMoneyException; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.Transaction; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class BsqTransferService { + + private final WalletsManager walletsManager; + private final BsqWalletService bsqWalletService; + private final BtcWalletService btcWalletService; + + @Inject + public BsqTransferService(WalletsManager walletsManager, + BsqWalletService bsqWalletService, + BtcWalletService btcWalletService) { + this.walletsManager = walletsManager; + this.bsqWalletService = bsqWalletService; + this.btcWalletService = btcWalletService; + } + + public BsqTransferModel getBsqTransferModel(LegacyAddress address, + Coin receiverAmount) + throws TransactionVerificationException, + WalletException, + BsqChangeBelowDustException, + InsufficientMoneyException { + + Transaction preparedSendTx = bsqWalletService.getPreparedSendBsqTx(address.toString(), receiverAmount); + Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true); + Transaction signedTx = bsqWalletService.signTx(txWithBtcFee); + + return new BsqTransferModel(address, + receiverAmount, + preparedSendTx, + txWithBtcFee, + signedTx); + } + + public void sendFunds(BsqTransferModel bsqTransferModel, TxBroadcaster.Callback callback) { + log.info("Publishing BSQ transfer {}", bsqTransferModel.toShortString()); + walletsManager.publishAndCommitBsqTx(bsqTransferModel.getTxWithBtcFee(), + bsqTransferModel.getTxType(), + callback); + } +} diff --git a/core/src/main/java/bisq/core/offer/OfferUtil.java b/core/src/main/java/bisq/core/offer/OfferUtil.java index e85881c2112..50fd938d6e9 100644 --- a/core/src/main/java/bisq/core/offer/OfferUtil.java +++ b/core/src/main/java/bisq/core/offer/OfferUtil.java @@ -49,6 +49,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.function.Predicate; import lombok.extern.slf4j.Slf4j; @@ -63,6 +64,7 @@ import static bisq.core.offer.OfferPayload.*; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; +import static java.lang.String.format; /** * This class holds utility methods for creating, editing and taking an Offer. @@ -79,6 +81,9 @@ public class OfferUtil { private final P2PService p2PService; private final ReferralIdService referralIdService; + private final Predicate isValidFeePaymentCurrencyCode = (c) -> + c.equalsIgnoreCase("BSQ") || c.equalsIgnoreCase("BTC"); + @Inject public OfferUtil(AccountAgeWitnessService accountAgeWitnessService, BsqWalletService bsqWalletService, @@ -96,6 +101,19 @@ public OfferUtil(AccountAgeWitnessService accountAgeWitnessService, this.referralIdService = referralIdService; } + public void maybeSetFeePaymentCurrencyPreference(String feeCurrencyCode) { + if (!feeCurrencyCode.isEmpty()) { + if (!isValidFeePaymentCurrencyCode.test(feeCurrencyCode)) + throw new IllegalStateException(format("%s cannot be used to pay trade fees", + feeCurrencyCode.toUpperCase())); + + if (feeCurrencyCode.equalsIgnoreCase("BSQ") && preferences.isPayFeeInBtc()) + preferences.setPayFeeInBtc(false); + else if (feeCurrencyCode.equalsIgnoreCase("BTC") && !preferences.isPayFeeInBtc()) + preferences.setPayFeeInBtc(true); + } + } + /** * Given the direction, is this a BUY? * diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java index f03155d2f8d..1efed108ca6 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java @@ -100,6 +100,7 @@ public void createOffer(CreateOfferRequest req, req.getMinAmount(), req.getBuyerSecurityDeposit(), req.getPaymentAccountId(), + req.getMakerFeeCurrencyCode(), offer -> { // This result handling consumer's accept operation will return // the new offer to the gRPC client after async placement is done. diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java index 74cd04ead93..449859a9f63 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java @@ -79,6 +79,7 @@ public void takeOffer(TakeOfferRequest req, try { coreApi.takeOffer(req.getOfferId(), req.getPaymentAccountId(), + req.getTakerFeeCurrencyCode(), trade -> { TradeInfo tradeInfo = toTradeInfo(trade); var reply = TakeOfferReply.newBuilder() diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java index 1b5cb42e4cc..110e4b5cc65 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcWalletsService.java @@ -19,17 +19,23 @@ import bisq.core.api.CoreApi; import bisq.core.api.model.AddressBalanceInfo; +import bisq.core.btc.exceptions.TxBroadcastException; +import bisq.core.btc.wallet.TxBroadcaster; import bisq.proto.grpc.GetAddressBalanceReply; import bisq.proto.grpc.GetAddressBalanceRequest; -import bisq.proto.grpc.GetBalanceReply; -import bisq.proto.grpc.GetBalanceRequest; +import bisq.proto.grpc.GetBalancesReply; +import bisq.proto.grpc.GetBalancesRequest; import bisq.proto.grpc.GetFundingAddressesReply; import bisq.proto.grpc.GetFundingAddressesRequest; +import bisq.proto.grpc.GetUnusedBsqAddressReply; +import bisq.proto.grpc.GetUnusedBsqAddressRequest; import bisq.proto.grpc.LockWalletReply; import bisq.proto.grpc.LockWalletRequest; import bisq.proto.grpc.RemoveWalletPasswordReply; import bisq.proto.grpc.RemoveWalletPasswordRequest; +import bisq.proto.grpc.SendBsqReply; +import bisq.proto.grpc.SendBsqRequest; import bisq.proto.grpc.SetWalletPasswordReply; import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.UnlockWalletReply; @@ -40,11 +46,16 @@ import io.grpc.StatusRuntimeException; import io.grpc.stub.StreamObserver; +import org.bitcoinj.core.Transaction; + import javax.inject.Inject; import java.util.List; import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; + +@Slf4j class GrpcWalletsService extends WalletsGrpc.WalletsImplBase { private final CoreApi coreApi; @@ -54,17 +65,13 @@ public GrpcWalletsService(CoreApi coreApi) { this.coreApi = coreApi; } - // TODO we need to support 3 or 4 balance types: available, reserved, lockedInTrade - // and maybe total wallet balance (available+reserved). To not duplicate the methods, - // we should pass an enum type. Enums in proto are a bit cumbersome as they are - // global so you quickly run into namespace conflicts if not always prefixes which - // makes it more verbose. In the core code base we move to the strategy to store the - // enum name and map it. This gives also more flexibility with updates. @Override - public void getBalance(GetBalanceRequest req, StreamObserver responseObserver) { + public void getBalances(GetBalancesRequest req, StreamObserver responseObserver) { try { - long availableBalance = coreApi.getAvailableBalance(); - var reply = GetBalanceReply.newBuilder().setBalance(availableBalance).build(); + var balances = coreApi.getBalances(req.getCurrencyCode()); + var reply = GetBalancesReply.newBuilder() + .setBalances(balances.toProtoMessage()) + .build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } catch (IllegalStateException cause) { @@ -110,6 +117,52 @@ public void getFundingAddresses(GetFundingAddressesRequest req, } } + @Override + public void getUnusedBsqAddress(GetUnusedBsqAddressRequest req, + StreamObserver responseObserver) { + try { + String address = coreApi.getUnusedBsqAddress(); + var reply = GetUnusedBsqAddressReply.newBuilder() + .setAddress(address) + .build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (IllegalStateException cause) { + var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); + responseObserver.onError(ex); + throw ex; + } + } + + @Override + 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(); + } + + @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); + throw ex; + } + } + @Override public void setWalletPassword(SetWalletPasswordRequest req, StreamObserver responseObserver) { diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index d9e0a3973d8..6dc0be30a59 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -81,6 +81,7 @@ message CreateOfferRequest { uint64 minAmount = 7; double buyerSecurityDeposit = 8; string paymentAccountId = 9; + string makerFeeCurrencyCode = 10; } message CreateOfferReply { @@ -105,13 +106,14 @@ message OfferInfo { uint64 volume = 8; uint64 minVolume = 9; uint64 buyerSecurityDeposit = 10; - string paymentAccountId = 11; - string paymentMethodId = 12; - string paymentMethodShortName = 13; - string baseCurrencyCode = 14; - string counterCurrencyCode = 15; - uint64 date = 16; - string state = 17; + bool isCurrencyForMakerFeeBtc = 11; + string paymentAccountId = 12; + string paymentMethodId = 13; + string paymentMethodShortName = 14; + string baseCurrencyCode = 15; + string counterCurrencyCode = 16; + uint64 date = 17; + string state = 18; } /////////////////////////////////////////////////////////////////////////////////////////// @@ -198,6 +200,7 @@ service Trades { message TakeOfferRequest { string offerId = 1; string paymentAccountId = 2; + string takerFeeCurrencyCode = 3; } message TakeOfferReply { @@ -273,10 +276,14 @@ message TradeInfo { /////////////////////////////////////////////////////////////////////////////////////////// service Wallets { - rpc GetBalance (GetBalanceRequest) returns (GetBalanceReply) { + rpc GetBalances (GetBalancesRequest) returns (GetBalancesReply) { } rpc GetAddressBalance (GetAddressBalanceRequest) returns (GetAddressBalanceReply) { } + rpc GetUnusedBsqAddress (GetUnusedBsqAddressRequest) returns (GetUnusedBsqAddressReply) { + } + rpc SendBsq (SendBsqRequest) returns (SendBsqReply) { + } rpc GetFundingAddresses (GetFundingAddressesRequest) returns (GetFundingAddressesReply) { } rpc SetWalletPassword (SetWalletPasswordRequest) returns (SetWalletPasswordReply) { @@ -289,11 +296,12 @@ service Wallets { } } -message GetBalanceRequest { +message GetBalancesRequest { + string currencyCode = 1; } -message GetBalanceReply { - uint64 balance = 1; +message GetBalancesReply { + BalancesInfo balances = 1; } message GetAddressBalanceRequest { @@ -304,6 +312,21 @@ message GetAddressBalanceReply { AddressBalanceInfo addressBalanceInfo = 1; } +message GetUnusedBsqAddressRequest { +} + +message GetUnusedBsqAddressReply { + string address = 1; +} + +message SendBsqRequest { + string address = 1; + double amount = 2; +} + +message SendBsqReply { +} + message GetFundingAddressesRequest { } @@ -340,6 +363,30 @@ message UnlockWalletRequest { message UnlockWalletReply { } +message BalancesInfo { + // Field names are shortened for readability's sake, i.e., + // balancesInfo.getBtc().getAvailableBalance() is cleaner than + // balancesInfo.getBtcBalanceInfo().getAvailableBalance(). + BsqBalanceInfo bsq = 1; + BtcBalanceInfo btc = 2; +} + +message BsqBalanceInfo { + uint64 availableConfirmedBalance = 1; + uint64 unverifiedBalance = 2; + uint64 unconfirmedChangeBalance = 3; + uint64 lockedForVotingBalance = 4; + uint64 lockupBondsBalance = 5; + uint64 unlockingBondsBalance = 6; +} + +message BtcBalanceInfo { + uint64 availableBalance = 1; + uint64 reservedBalance = 2; + uint64 totalAvailableBalance = 3; + uint64 lockedBalance = 4; +} + message AddressBalanceInfo { string address = 1; int64 balance = 2;