diff --git a/apitest/src/test/java/bisq/apitest/ApiTestCase.java b/apitest/src/test/java/bisq/apitest/ApiTestCase.java index 144df9d3e99..854ce4c59bc 100644 --- a/apitest/src/test/java/bisq/apitest/ApiTestCase.java +++ b/apitest/src/test/java/bisq/apitest/ApiTestCase.java @@ -105,7 +105,12 @@ protected static GrpcStubs grpcStubs(BisqAppConfig bisqAppConfig) { } } - protected void sleep(long ms) { + protected static void genBtcBlocksThenWait(int numBlocks, long wait) { + bitcoinCli.generateBlocks(numBlocks); + sleep(wait); + } + + protected static void sleep(long ms) { try { MILLISECONDS.sleep(ms); } catch (InterruptedException ignored) { diff --git a/apitest/src/test/java/bisq/apitest/method/MethodTest.java b/apitest/src/test/java/bisq/apitest/method/MethodTest.java index b03f00da356..6a175d118ce 100644 --- a/apitest/src/test/java/bisq/apitest/method/MethodTest.java +++ b/apitest/src/test/java/bisq/apitest/method/MethodTest.java @@ -17,17 +17,22 @@ package bisq.apitest.method; +import bisq.proto.grpc.ConfirmPaymentReceivedRequest; +import bisq.proto.grpc.ConfirmPaymentStartedRequest; import bisq.proto.grpc.CreatePaymentAccountRequest; import bisq.proto.grpc.GetBalanceRequest; import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.GetOfferRequest; import bisq.proto.grpc.GetPaymentAccountsRequest; +import bisq.proto.grpc.GetTradeRequest; 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.SetWalletPasswordRequest; +import bisq.proto.grpc.TakeOfferRequest; +import bisq.proto.grpc.TradeInfo; import bisq.proto.grpc.UnlockWalletRequest; import protobuf.PaymentAccount; @@ -88,6 +93,22 @@ protected final GetOfferRequest createGetOfferRequest(String offerId) { return GetOfferRequest.newBuilder().setId(offerId).build(); } + protected final TakeOfferRequest createTakeOfferRequest(String offerId, String paymentAccountId) { + return TakeOfferRequest.newBuilder().setOfferId(offerId).setPaymentAccountId(paymentAccountId).build(); + } + + protected final GetTradeRequest createGetTradeRequest(String tradeId) { + return GetTradeRequest.newBuilder().setTradeId(tradeId).build(); + } + + protected final ConfirmPaymentStartedRequest createConfirmPaymentStartedRequest(String tradeId) { + return ConfirmPaymentStartedRequest.newBuilder().setTradeId(tradeId).build(); + } + + protected final ConfirmPaymentReceivedRequest createConfirmPaymentReceivedRequest(String tradeId) { + return ConfirmPaymentReceivedRequest.newBuilder().setTradeId(tradeId).build(); + } + // Convenience methods for calling frequently used & thoroughly tested gRPC services. protected final long getBalance(BisqAppConfig bisqAppConfig) { @@ -149,6 +170,21 @@ protected final OfferInfo getOffer(BisqAppConfig bisqAppConfig, String offerId) return grpcStubs(bisqAppConfig).offersService.getOffer(req).getOffer(); } + protected final TradeInfo getTrade(BisqAppConfig bisqAppConfig, String tradeId) { + var req = createGetTradeRequest(tradeId); + return grpcStubs(bisqAppConfig).tradesService.getTrade(req).getTrade(); + } + + protected final void confirmPaymentStarted(BisqAppConfig bisqAppConfig, String tradeId) { + var req = createConfirmPaymentStartedRequest(tradeId); + grpcStubs(bisqAppConfig).tradesService.confirmPaymentStarted(req); + } + + protected final void confirmPaymentReceived(BisqAppConfig bisqAppConfig, String tradeId) { + var req = createConfirmPaymentReceivedRequest(tradeId); + grpcStubs(bisqAppConfig).tradesService.confirmPaymentReceived(req); + } + // Static conveniences for test methods and test case fixture setups. protected static RegisterDisputeAgentRequest createRegisterDisputeAgentRequest(String disputeAgentType) { diff --git a/apitest/src/test/java/bisq/apitest/method/offer/AbstractCreateOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java similarity index 55% rename from apitest/src/test/java/bisq/apitest/method/offer/AbstractCreateOfferTest.java rename to apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java index 9c494a7a74d..979d7a33e6a 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/AbstractCreateOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java @@ -19,9 +19,12 @@ import bisq.core.monetary.Altcoin; +import bisq.proto.grpc.CreateOfferRequest; import bisq.proto.grpc.GetOffersRequest; import bisq.proto.grpc.OfferInfo; +import protobuf.PaymentAccount; + import org.bitcoinj.utils.Fiat; import java.math.BigDecimal; @@ -36,14 +39,16 @@ 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.common.util.MathUtils.roundDouble; import static bisq.common.util.MathUtils.scaleDownByPowerOf10; +import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; import static bisq.core.locale.CurrencyUtil.isCryptoCurrency; import static java.lang.String.format; import static java.math.RoundingMode.HALF_UP; import static java.util.Comparator.comparing; -import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.junit.jupiter.api.Assertions.fail; @@ -51,11 +56,11 @@ import bisq.apitest.method.MethodTest; import bisq.cli.GrpcStubs; - @Slf4j -abstract class AbstractCreateOfferTest extends MethodTest { +public abstract class AbstractOfferTest extends MethodTest { protected static GrpcStubs aliceStubs; + protected static GrpcStubs bobStubs; @BeforeAll public static void setUp() { @@ -64,36 +69,74 @@ public static void setUp() { static void startSupportingApps() { try { - // setUpScaffold(new String[]{"--supportingApps", "bitcoind,seednode,alicedaemon", "--enableBisqDebugging", "true"}); - setUpScaffold(bitcoind, seednode, alicedaemon); + // setUpScaffold(new String[]{"--supportingApps", "bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon", "--enableBisqDebugging", "true"}); + setUpScaffold(bitcoind, seednode, arbdaemon, alicedaemon, bobdaemon); + registerDisputeAgents(arbdaemon); aliceStubs = grpcStubs(alicedaemon); + bobStubs = grpcStubs(bobdaemon); // Generate 1 regtest block for alice's wallet to show 10 BTC balance, // and give alicedaemon time to parse the new block. - bitcoinCli.generateBlocks(1); - MILLISECONDS.sleep(1500); + genBtcBlocksThenWait(1, 1500); } catch (Exception ex) { fail(ex); } } + protected final OfferInfo createAliceOffer(PaymentAccount paymentAccount, + String direction, + String currencyCode, + long amount) { + return createMarketBasedPricedOffer(aliceStubs, paymentAccount, direction, currencyCode, amount); + } + + protected final OfferInfo createBobOffer(PaymentAccount paymentAccount, + String direction, + String currencyCode, + long amount) { + return createMarketBasedPricedOffer(bobStubs, paymentAccount, direction, currencyCode, amount); + } + + protected final OfferInfo createMarketBasedPricedOffer(GrpcStubs grpcStubs, + PaymentAccount paymentAccount, + String direction, + String currencyCode, + long amount) { + var req = CreateOfferRequest.newBuilder() + .setPaymentAccountId(paymentAccount.getId()) + .setDirection(direction) + .setCurrencyCode(currencyCode) + .setAmount(amount) + .setMinAmount(amount) + .setUseMarketBasedPrice(true) + .setMarketPriceMargin(0.00) + .setPrice("0") + .setBuyerSecurityDeposit(getDefaultBuyerSecurityDepositAsPercent()) + .build(); + return grpcStubs.offersService.createOffer(req).getOffer(); + } + protected final OfferInfo getOffer(String offerId) { return aliceStubs.offersService.getOffer(createGetOfferRequest(offerId)).getOffer(); } - protected final OfferInfo getMostRecentOffer(String direction, String currencyCode) { - List offerInfoList = getOffersSortedByDate(direction, currencyCode); + protected final OfferInfo getMostRecentOffer(GrpcStubs grpcStubs, String direction, String currencyCode) { + List offerInfoList = getOffersSortedByDate(grpcStubs, direction, currencyCode); if (offerInfoList.isEmpty()) fail(format("No %s offers found for currency %s", direction, currencyCode)); return offerInfoList.get(offerInfoList.size() - 1); } - protected final List getOffersSortedByDate(String direction, String currencyCode) { + protected final int getOpenOffersCount(GrpcStubs grpcStubs, String direction, String currencyCode) { + return getOffersSortedByDate(grpcStubs, direction, currencyCode).size(); + } + + protected final List getOffersSortedByDate(GrpcStubs grpcStubs, String direction, String currencyCode) { var req = GetOffersRequest.newBuilder() .setDirection(direction) .setCurrencyCode(currencyCode).build(); - var reply = aliceStubs.offersService.getOffers(req); + var reply = grpcStubs.offersService.getOffers(req); return sortOffersByDate(reply.getOffersList()); } 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 39606536558..739faf71e96 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingFixedPriceTest.java @@ -35,7 +35,7 @@ @Slf4j @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public class CreateOfferUsingFixedPriceTest extends AbstractCreateOfferTest { +public class CreateOfferUsingFixedPriceTest extends AbstractOfferTest { @Test @Order(1) 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 331ddbab736..f9d379131bb 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java @@ -43,7 +43,7 @@ @Slf4j @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public class CreateOfferUsingMarketPriceMarginTest extends AbstractCreateOfferTest { +public class CreateOfferUsingMarketPriceMarginTest extends AbstractOfferTest { private static final DecimalFormat PCT_FORMAT = new DecimalFormat("##0.00"); private static final double MKT_PRICE_MARGIN_ERROR_TOLERANCE = 0.0050; // 0.50% 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 c51b64a6a0f..785dc97fdcb 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/ValidateCreateOfferTest.java @@ -36,7 +36,7 @@ @Slf4j @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public class ValidateCreateOfferTest extends AbstractCreateOfferTest { +public class ValidateCreateOfferTest extends AbstractOfferTest { @Test @Order(1) diff --git a/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java new file mode 100644 index 00000000000..5364de7f7d8 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/trade/AbstractTradeTest.java @@ -0,0 +1,32 @@ +package bisq.apitest.method.trade; + +import bisq.core.trade.Trade; + +import bisq.proto.grpc.TradeInfo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + + +import bisq.apitest.method.offer.AbstractOfferTest; + +public class AbstractTradeTest extends AbstractOfferTest { + + protected final TradeInfo takeAlicesOffer(String offerId, String paymentAccountId) { + return bobStubs.tradesService.takeOffer(createTakeOfferRequest(offerId, paymentAccountId)).getTrade(); + } + + protected final TradeInfo takeBobsOffer(String offerId, String paymentAccountId) { + return aliceStubs.tradesService.takeOffer(createTakeOfferRequest(offerId, paymentAccountId)).getTrade(); + } + + protected final void verifyExpectedTradeStateAndPhase(TradeInfo trade, + Trade.State expectedState, + Trade.Phase expectedPhase) { + assertNotNull(trade); + assertEquals(expectedState.name(), trade.getState()); + assertEquals(expectedPhase.name(), trade.getPhase()); + } + +} diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java new file mode 100644 index 00000000000..040cee5a18d --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java @@ -0,0 +1,129 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.method.trade; + +import protobuf.PaymentAccount; + +import io.grpc.StatusRuntimeException; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import static bisq.apitest.config.BisqAppConfig.alicedaemon; +import static bisq.apitest.config.BisqAppConfig.bobdaemon; +import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED; +import static bisq.core.trade.Trade.Phase.DEPOSIT_PUBLISHED; +import static bisq.core.trade.Trade.Phase.FIAT_SENT; +import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED; +import static bisq.core.trade.Trade.State.BUYER_SAW_ARRIVED_FIAT_PAYMENT_INITIATED_MSG; +import static bisq.core.trade.Trade.State.DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN; +import static bisq.core.trade.Trade.State.SELLER_PUBLISHED_DEPOSIT_TX; +import static bisq.core.trade.Trade.State.SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; +import static protobuf.Offer.State.OFFER_FEE_PAID; +import static protobuf.OpenOffer.State.AVAILABLE; + +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class TakeBuyBTCOfferTest extends AbstractTradeTest { + + // Alice is buyer, Bob is seller. + + private static String tradeId; + + private PaymentAccount alicesAccount; + private PaymentAccount bobsAccount; + + @BeforeEach + public void init() { + alicesAccount = getDefaultPerfectDummyPaymentAccount(alicedaemon); + bobsAccount = getDefaultPerfectDummyPaymentAccount(bobdaemon); + } + + @Test + @Order(1) + public void testTakeAlicesBuyOffer() { + try { + var alicesOffer = createAliceOffer(alicesAccount, "buy", "usd", 12500000); + var offerId = alicesOffer.getId(); + + // Wait for Alice's AddToOfferBook task. + // Wait times vary; my logs show >= 2 second delay. + sleep(3000); + assertEquals(1, getOpenOffersCount(aliceStubs, "buy", "usd")); + + var trade = takeAlicesOffer(offerId, bobsAccount.getId()); + assertNotNull(trade); + assertEquals(offerId, trade.getTradeId()); + // Cache the trade id for the other tests. + tradeId = trade.getTradeId(); + + genBtcBlocksThenWait(1, 2250); + assertEquals(0, getOpenOffersCount(aliceStubs, "buy", "usd")); + + trade = getTrade(bobdaemon, trade.getTradeId()); + verifyExpectedTradeStateAndPhase(trade, SELLER_PUBLISHED_DEPOSIT_TX, DEPOSIT_PUBLISHED); + + genBtcBlocksThenWait(1, 2250); + trade = getTrade(bobdaemon, trade.getTradeId()); + verifyExpectedTradeStateAndPhase(trade, DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN, DEPOSIT_CONFIRMED); + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(2) + public void testAlicesConfirmPaymentStarted() { + try { + var trade = getTrade(alicedaemon, tradeId); + assertNotNull(trade); + + confirmPaymentStarted(alicedaemon, trade.getTradeId()); + sleep(3000); + + trade = getTrade(alicedaemon, tradeId); + assertEquals(OFFER_FEE_PAID.name(), trade.getOffer().getState()); + verifyExpectedTradeStateAndPhase(trade, BUYER_SAW_ARRIVED_FIAT_PAYMENT_INITIATED_MSG, FIAT_SENT); + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(3) + public void testBobsConfirmPaymentReceived() { + var trade = getTrade(bobdaemon, tradeId); + assertNotNull(trade); + + confirmPaymentReceived(bobdaemon, trade.getTradeId()); + sleep(3000); + + trade = getTrade(bobdaemon, tradeId); + // TODO is this a bug? Why is offer.state == available? + assertEquals(AVAILABLE.name(), trade.getOffer().getState()); + verifyExpectedTradeStateAndPhase(trade, SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG, PAYOUT_PUBLISHED); + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java new file mode 100644 index 00000000000..5347159daae --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java @@ -0,0 +1,131 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.method.trade; + +import protobuf.PaymentAccount; + +import io.grpc.StatusRuntimeException; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import static bisq.apitest.config.BisqAppConfig.alicedaemon; +import static bisq.apitest.config.BisqAppConfig.bobdaemon; +import static bisq.core.trade.Trade.Phase.DEPOSIT_CONFIRMED; +import static bisq.core.trade.Trade.Phase.DEPOSIT_PUBLISHED; +import static bisq.core.trade.Trade.Phase.FIAT_SENT; +import static bisq.core.trade.Trade.Phase.PAYOUT_PUBLISHED; +import static bisq.core.trade.Trade.State.BUYER_RECEIVED_DEPOSIT_TX_PUBLISHED_MSG; +import static bisq.core.trade.Trade.State.BUYER_SAW_ARRIVED_FIAT_PAYMENT_INITIATED_MSG; +import static bisq.core.trade.Trade.State.DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN; +import static bisq.core.trade.Trade.State.SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; +import static protobuf.Offer.State.OFFER_FEE_PAID; +import static protobuf.OpenOffer.State.AVAILABLE; + +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class TakeSellBTCOfferTest extends AbstractTradeTest { + + // Alice is seller, Bob is buyer. + + private static String tradeId; + + private PaymentAccount alicesAccount; + private PaymentAccount bobsAccount; + + @BeforeEach + public void init() { + alicesAccount = getDefaultPerfectDummyPaymentAccount(alicedaemon); + bobsAccount = getDefaultPerfectDummyPaymentAccount(bobdaemon); + } + + @Test + @Order(1) + public void testTakeAlicesSellOffer() { + try { + var alicesOffer = createAliceOffer(alicesAccount, "sell", "usd", 12500000); + var offerId = alicesOffer.getId(); + + // 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); + assertEquals(1, getOpenOffersCount(bobStubs, "sell", "usd")); + + var trade = takeAlicesOffer(offerId, bobsAccount.getId()); + assertNotNull(trade); + assertEquals(offerId, trade.getTradeId()); + // Cache the trade id for the other tests. + tradeId = trade.getTradeId(); + + genBtcBlocksThenWait(1, 4000); + assertEquals(0, getOpenOffersCount(bobStubs, "sell", "usd")); + + trade = getTrade(bobdaemon, trade.getTradeId()); + verifyExpectedTradeStateAndPhase(trade, BUYER_RECEIVED_DEPOSIT_TX_PUBLISHED_MSG, DEPOSIT_PUBLISHED); + + genBtcBlocksThenWait(1, 2250); + trade = getTrade(bobdaemon, trade.getTradeId()); + verifyExpectedTradeStateAndPhase(trade, DEPOSIT_CONFIRMED_IN_BLOCK_CHAIN, DEPOSIT_CONFIRMED); + + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(2) + public void testBobsConfirmPaymentStarted() { + try { + var trade = getTrade(bobdaemon, tradeId); + assertNotNull(trade); + + confirmPaymentStarted(bobdaemon, trade.getTradeId()); + sleep(3000); + + trade = getTrade(bobdaemon, tradeId); + // TODO is this a bug? Why is offer.state == available? + assertEquals(AVAILABLE.name(), trade.getOffer().getState()); + verifyExpectedTradeStateAndPhase(trade, BUYER_SAW_ARRIVED_FIAT_PAYMENT_INITIATED_MSG, FIAT_SENT); + } catch (StatusRuntimeException e) { + fail(e); + } + } + + @Test + @Order(3) + public void testAlicesConfirmPaymentReceived() { + var trade = getTrade(alicedaemon, tradeId); + assertNotNull(trade); + + confirmPaymentReceived(alicedaemon, trade.getTradeId()); + sleep(3000); + + trade = getTrade(alicedaemon, tradeId); + assertEquals(OFFER_FEE_PAID.name(), trade.getOffer().getState()); + verifyExpectedTradeStateAndPhase(trade, SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG, PAYOUT_PUBLISHED); + } +} diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index 9b73e560283..d8f66dabbef 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -17,6 +17,8 @@ package bisq.cli; +import bisq.proto.grpc.ConfirmPaymentReceivedRequest; +import bisq.proto.grpc.ConfirmPaymentStartedRequest; import bisq.proto.grpc.CreateOfferRequest; import bisq.proto.grpc.CreatePaymentAccountRequest; import bisq.proto.grpc.GetAddressBalanceRequest; @@ -30,6 +32,7 @@ import bisq.proto.grpc.RegisterDisputeAgentRequest; import bisq.proto.grpc.RemoveWalletPasswordRequest; import bisq.proto.grpc.SetWalletPasswordRequest; +import bisq.proto.grpc.TakeOfferRequest; import bisq.proto.grpc.UnlockWalletRequest; import io.grpc.StatusRuntimeException; @@ -70,6 +73,9 @@ private enum Method { createoffer, getoffer, getoffers, + takeoffer, + confirmpaymentstarted, + confirmpaymentreceived, createpaymentacct, getpaymentaccts, getversion, @@ -154,9 +160,10 @@ public static void run(String[] args) { GrpcStubs grpcStubs = new GrpcStubs(host, port, password); var disputeAgentsService = grpcStubs.disputeAgentsService; - var versionService = grpcStubs.versionService; var offersService = grpcStubs.offersService; var paymentAccountsService = grpcStubs.paymentAccountsService; + var tradesService = grpcStubs.tradesService; + var versionService = grpcStubs.versionService; var walletsService = grpcStubs.walletsService; try { @@ -254,6 +261,44 @@ public static void run(String[] args) { out.println(formatOfferTable(reply.getOffersList(), currencyCode)); return; } + case takeoffer: { + if (nonOptionArgs.size() < 3) + throw new IllegalArgumentException("incorrect parameter count, expecting offer id, payment acct id"); + + var offerId = nonOptionArgs.get(1); + var paymentAccountId = nonOptionArgs.get(2); + var request = TakeOfferRequest.newBuilder() + .setOfferId(offerId) + .setPaymentAccountId(paymentAccountId) + .build(); + var reply = tradesService.takeOffer(request); + out.printf("trade '%s' successfully taken", reply.getTrade().getShortId()); + return; + } + case confirmpaymentstarted: { + if (nonOptionArgs.size() < 2) + throw new IllegalArgumentException("incorrect parameter count, expecting trade id"); + + var tradeId = nonOptionArgs.get(1); + var request = ConfirmPaymentStartedRequest.newBuilder() + .setTradeId(tradeId) + .build(); + tradesService.confirmPaymentStarted(request); + out.printf("trade '%s' payment started message sent", tradeId); + return; + } + case confirmpaymentreceived: { + if (nonOptionArgs.size() < 2) + throw new IllegalArgumentException("incorrect parameter count, expecting trade id"); + + var tradeId = nonOptionArgs.get(1); + var request = ConfirmPaymentReceivedRequest.newBuilder() + .setTradeId(tradeId) + .build(); + tradesService.confirmPaymentReceived(request); + out.printf("trade '%s' payment received message sent", tradeId); + return; + } case createpaymentacct: { if (nonOptionArgs.size() < 5) throw new IllegalArgumentException( @@ -381,6 +426,9 @@ private static void printHelp(OptionParser parser, PrintStream stream) { stream.format(rowFormat, "", "security deposit (%)", ""); 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, "confirmpaymentstarted", "trade id", "Confirm payment started"); + stream.format(rowFormat, "confirmpaymentreceived", "trade id", "Confirm payment received"); stream.format(rowFormat, "createpaymentacct", "account name, account number, currency code", "Create PerfectMoney dummy account"); stream.format(rowFormat, "getpaymentaccts", "", "Get user payment accounts"); stream.format(rowFormat, "lockwallet", "", "Remove wallet password from memory, locking the wallet"); diff --git a/cli/src/main/java/bisq/cli/GrpcStubs.java b/cli/src/main/java/bisq/cli/GrpcStubs.java index 0e5835a278c..2db33fcbaa9 100644 --- a/cli/src/main/java/bisq/cli/GrpcStubs.java +++ b/cli/src/main/java/bisq/cli/GrpcStubs.java @@ -22,6 +22,7 @@ import bisq.proto.grpc.OffersGrpc; import bisq.proto.grpc.PaymentAccountsGrpc; import bisq.proto.grpc.PriceGrpc; +import bisq.proto.grpc.TradesGrpc; import bisq.proto.grpc.WalletsGrpc; import io.grpc.CallCredentials; @@ -36,6 +37,7 @@ public class GrpcStubs { public final OffersGrpc.OffersBlockingStub offersService; public final PaymentAccountsGrpc.PaymentAccountsBlockingStub paymentAccountsService; public final PriceGrpc.PriceBlockingStub priceService; + public final TradesGrpc.TradesBlockingStub tradesService; public final WalletsGrpc.WalletsBlockingStub walletsService; public GrpcStubs(String apiHost, int apiPort, String apiPassword) { @@ -55,6 +57,7 @@ public GrpcStubs(String apiHost, int apiPort, String apiPassword) { this.offersService = OffersGrpc.newBlockingStub(channel).withCallCredentials(credentials); this.paymentAccountsService = PaymentAccountsGrpc.newBlockingStub(channel).withCallCredentials(credentials); this.priceService = PriceGrpc.newBlockingStub(channel).withCallCredentials(credentials); + this.tradesService = TradesGrpc.newBlockingStub(channel).withCallCredentials(credentials); this.walletsService = WalletsGrpc.newBlockingStub(channel).withCallCredentials(credentials); } } diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java index 08cc67c49dc..01f70d312f4 100644 --- a/core/src/main/java/bisq/core/api/CoreApi.java +++ b/core/src/main/java/bisq/core/api/CoreApi.java @@ -22,6 +22,7 @@ import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; import bisq.core.payment.PaymentAccount; +import bisq.core.trade.Trade; import bisq.core.trade.statistics.TradeStatistics3; import bisq.core.trade.statistics.TradeStatisticsManager; @@ -51,6 +52,7 @@ public class CoreApi { private final CoreOffersService coreOffersService; private final CorePaymentAccountsService paymentAccountsService; private final CorePriceService corePriceService; + private final CoreTradesService coreTradesService; private final CoreWalletsService walletsService; private final TradeStatisticsManager tradeStatisticsManager; @@ -59,12 +61,14 @@ public CoreApi(CoreDisputeAgentsService coreDisputeAgentsService, CoreOffersService coreOffersService, CorePaymentAccountsService paymentAccountsService, CorePriceService corePriceService, + CoreTradesService coreTradesService, CoreWalletsService walletsService, TradeStatisticsManager tradeStatisticsManager) { this.coreDisputeAgentsService = coreDisputeAgentsService; this.coreOffersService = coreOffersService; - this.corePriceService = corePriceService; this.paymentAccountsService = paymentAccountsService; + this.coreTradesService = coreTradesService; + this.corePriceService = corePriceService; this.walletsService = walletsService; this.tradeStatisticsManager = tradeStatisticsManager; } @@ -164,6 +168,31 @@ public double getMarketPrice(String currencyCode) { return corePriceService.getMarketPrice(currencyCode); } + /////////////////////////////////////////////////////////////////////////////////////////// + // Trades + /////////////////////////////////////////////////////////////////////////////////////////// + + public void takeOffer(String offerId, + String paymentAccountId, + Consumer resultHandler) { + Offer offer = coreOffersService.getOffer(offerId); + coreTradesService.takeOffer(offer, + paymentAccountId, + resultHandler); + } + + public void confirmPaymentStarted(String tradeId) { + coreTradesService.confirmPaymentStarted(tradeId); + } + + public void confirmPaymentReceived(String tradeId) { + coreTradesService.confirmPaymentReceived(tradeId); + } + + public Trade getTrade(String tradeId) { + return coreTradesService.getTrade(tradeId); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Wallets /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/bisq/core/api/CoreTradesService.java b/core/src/main/java/bisq/core/api/CoreTradesService.java new file mode 100644 index 00000000000..d0200a78ce5 --- /dev/null +++ b/core/src/main/java/bisq/core/api/CoreTradesService.java @@ -0,0 +1,122 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.api; + +import bisq.core.offer.Offer; +import bisq.core.offer.takeoffer.TakeOfferModel; +import bisq.core.trade.Trade; +import bisq.core.trade.TradeManager; +import bisq.core.trade.protocol.BuyerProtocol; +import bisq.core.trade.protocol.SellerProtocol; +import bisq.core.user.User; + +import javax.inject.Inject; + +import java.util.function.Consumer; + +import lombok.extern.slf4j.Slf4j; + +import static java.lang.String.format; + +@Slf4j +class CoreTradesService { + + private final TakeOfferModel takeOfferModel; + private final TradeManager tradeManager; + private final User user; + + @Inject + public CoreTradesService(TakeOfferModel takeOfferModel, + TradeManager tradeManager, + User user) { + this.takeOfferModel = takeOfferModel; + this.tradeManager = tradeManager; + this.user = user; + } + + void takeOffer(Offer offer, + String paymentAccountId, + Consumer resultHandler) { + var paymentAccount = user.getPaymentAccount(paymentAccountId); + if (paymentAccount == null) + throw new IllegalArgumentException(format("payment account with id '%s' not found", paymentAccountId)); + + var useSavingsWallet = true; + takeOfferModel.initModel(offer, paymentAccount, useSavingsWallet); + log.info("Initiating take {} offer, {}", + offer.isBuyOffer() ? "buy" : "sell", + takeOfferModel); + //noinspection ConstantConditions + tradeManager.onTakeOffer(offer.getAmount(), + takeOfferModel.getTxFeeFromFeeService(), + takeOfferModel.getTakerFee(), + takeOfferModel.isCurrencyForTakerFeeBtc(), + offer.getPrice().getValue(), + takeOfferModel.getFundsNeededForTrade(), + offer, + paymentAccountId, + useSavingsWallet, + resultHandler::accept, + errorMessage -> { + log.error(errorMessage); + throw new IllegalStateException(errorMessage); + } + ); + } + + void confirmPaymentStarted(String tradeId) { + var trade = getTrade(tradeId); + if (isFollowingBuyerProtocol(trade)) { + var tradeProtocol = tradeManager.getTradeProtocol(trade); + ((BuyerProtocol) tradeProtocol).onPaymentStarted( + () -> { + }, + errorMessage -> { + throw new IllegalStateException(errorMessage); + } + ); + } else { + throw new IllegalStateException("you are the seller and not sending payment"); + } + } + + void confirmPaymentReceived(String tradeId) { + var trade = getTrade(tradeId); + if (isFollowingBuyerProtocol(trade)) { + throw new IllegalStateException("you are the buyer, and not receiving payment"); + } else { + var tradeProtocol = tradeManager.getTradeProtocol(trade); + ((SellerProtocol) tradeProtocol).onPaymentReceived( + () -> { + }, + errorMessage -> { + throw new IllegalStateException(errorMessage); + } + ); + } + } + + Trade getTrade(String tradeId) { + return tradeManager.getTradeById(tradeId).orElseThrow(() -> + new IllegalArgumentException(format("trade with id '%s' not found", tradeId))); + } + + private boolean isFollowingBuyerProtocol(Trade trade) { + return tradeManager.getTradeProtocol(trade) instanceof BuyerProtocol; + } +} 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 fa6f0c95ea6..ad2389e438a 100644 --- a/core/src/main/java/bisq/core/api/model/OfferInfo.java +++ b/core/src/main/java/bisq/core/api/model/OfferInfo.java @@ -17,8 +17,12 @@ package bisq.core.api.model; +import bisq.core.offer.Offer; + import bisq.common.Payload; +import java.util.Objects; + import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; @@ -28,6 +32,10 @@ @Getter public class OfferInfo implements Payload { + // The client cannot see bisq.core.Offer or its fromProto method. We use the lighter + // weight OfferInfo proto wrapper instead, containing just enough fields to view, + // create and take offers. + private final String id; private final String direction; private final long price; @@ -46,6 +54,7 @@ public class OfferInfo implements Payload { private final String baseCurrencyCode; private final String counterCurrencyCode; private final long date; + private final String state; public OfferInfo(OfferInfoBuilder builder) { this.id = builder.id; @@ -64,6 +73,29 @@ public OfferInfo(OfferInfoBuilder builder) { this.baseCurrencyCode = builder.baseCurrencyCode; this.counterCurrencyCode = builder.counterCurrencyCode; this.date = builder.date; + this.state = builder.state; + } + + public static OfferInfo toOfferInfo(Offer offer) { + return new OfferInfo.OfferInfoBuilder() + .withId(offer.getId()) + .withDirection(offer.getDirection().name()) + .withPrice(Objects.requireNonNull(offer.getPrice()).getValue()) + .withUseMarketBasedPrice(offer.isUseMarketBasedPrice()) + .withMarketPriceMargin(offer.getMarketPriceMargin()) + .withAmount(offer.getAmount().value) + .withMinAmount(offer.getMinAmount().value) + .withVolume(Objects.requireNonNull(offer.getVolume()).getValue()) + .withMinVolume(Objects.requireNonNull(offer.getMinVolume()).getValue()) + .withBuyerSecurityDeposit(offer.getBuyerSecurityDeposit().value) + .withPaymentAccountId(offer.getMakerPaymentAccountId()) + .withPaymentMethodId(offer.getPaymentMethod().getId()) + .withPaymentMethodShortName(offer.getPaymentMethod().getShortName()) + .withBaseCurrencyCode(offer.getOfferPayload().getBaseCurrencyCode()) + .withCounterCurrencyCode(offer.getOfferPayload().getCounterCurrencyCode()) + .withDate(offer.getDate().getTime()) + .withState(offer.getState().name()) + .build(); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -89,12 +121,13 @@ public bisq.proto.grpc.OfferInfo toProtoMessage() { .setBaseCurrencyCode(baseCurrencyCode) .setCounterCurrencyCode(counterCurrencyCode) .setDate(date) + .setState(state) .build(); } public static OfferInfo fromProto(bisq.proto.grpc.OfferInfo proto) { /* - TODO (will be needed by the createoffer method) + TODO? return new OfferInfo(proto.getOfferPayload().getId(), proto.getOfferPayload().getDate()); */ @@ -124,9 +157,7 @@ public static class OfferInfoBuilder { private String baseCurrencyCode; private String counterCurrencyCode; private long date; - - public OfferInfoBuilder() { - } + private String state; public OfferInfoBuilder withId(String id) { this.id = id; @@ -208,6 +239,11 @@ public OfferInfoBuilder withDate(long date) { return this; } + public OfferInfoBuilder withState(String state) { + this.state = state; + return this; + } + public OfferInfo build() { return new OfferInfo(this); } diff --git a/core/src/main/java/bisq/core/api/model/TradeInfo.java b/core/src/main/java/bisq/core/api/model/TradeInfo.java new file mode 100644 index 00000000000..33b8059f9b5 --- /dev/null +++ b/core/src/main/java/bisq/core/api/model/TradeInfo.java @@ -0,0 +1,211 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.api.model; + +import bisq.core.trade.Trade; + +import bisq.common.Payload; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import static bisq.core.api.model.OfferInfo.toOfferInfo; + +@EqualsAndHashCode +@Getter +public class TradeInfo implements Payload { + + // The client cannot see bisq.core.trade.Trade or its fromProto method. We use the + // lighter weight TradeInfo proto wrapper instead, containing just enough fields to + // view and interact with trades. + + private final OfferInfo offer; + private final String tradeId; + private final String shortId; + private final String state; + private final String phase; + private final String tradePeriodState; + private final boolean isDepositPublished; + private final boolean isDepositConfirmed; + private final boolean isFiatSent; + private final boolean isFiatReceived; + private final boolean isPayoutPublished; + private final boolean isWithdrawn; + + public TradeInfo(TradeInfoBuilder builder) { + this.offer = builder.offer; + this.tradeId = builder.tradeId; + this.shortId = builder.shortId; + this.state = builder.state; + this.phase = builder.phase; + this.tradePeriodState = builder.tradePeriodState; + this.isDepositPublished = builder.isDepositPublished; + this.isDepositConfirmed = builder.isDepositConfirmed; + this.isFiatSent = builder.isFiatSent; + this.isFiatReceived = builder.isFiatReceived; + this.isPayoutPublished = builder.isPayoutPublished; + this.isWithdrawn = builder.isWithdrawn; + } + + public static TradeInfo toTradeInfo(Trade trade) { + return new TradeInfo.TradeInfoBuilder() + .withOffer(toOfferInfo(trade.getOffer())) + .withTradeId(trade.getId()) + .withShortId(trade.getShortId()) + .withState(trade.getState().name()) + .withPhase(trade.getPhase().name()) + .withTradePeriodState(trade.getTradePeriodState().name()) + .withIsDepositPublished(trade.isDepositPublished()) + .withIsDepositConfirmed(trade.isDepositConfirmed()) + .withIsFiatSent(trade.isFiatSent()) + .withIsFiatReceived(trade.isFiatReceived()) + .withIsPayoutPublished(trade.isPayoutPublished()) + .withIsWithdrawn(trade.isWithdrawn()) + .build(); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public bisq.proto.grpc.TradeInfo toProtoMessage() { + return bisq.proto.grpc.TradeInfo.newBuilder() + .setOffer(offer.toProtoMessage()) + .setTradeId(tradeId) + .setShortId(shortId) + .setState(state) + .setPhase(phase) + .setTradePeriodState(tradePeriodState) + .setIsDepositPublished(isDepositPublished) + .setIsDepositConfirmed(isDepositConfirmed) + .setIsFiatSent(isFiatSent) + .setIsFiatReceived(isFiatReceived) + .setIsPayoutPublished(isPayoutPublished) + .setIsWithdrawn(isWithdrawn) + .build(); + } + + public static TradeInfo fromProto(bisq.proto.grpc.TradeInfo proto) { + // TODO + return null; + } + + /* + * TradeInfoBuilder helps avoid bungling use of a large TradeInfo constructor + * argument list. If consecutive argument values of the same type are not + * ordered correctly, the compiler won't complain but the resulting bugs could + * be hard to find and fix. + */ + public static class TradeInfoBuilder { + private OfferInfo offer; + private String tradeId; + private String shortId; + private String state; + private String phase; + private String tradePeriodState; + private boolean isDepositPublished; + private boolean isDepositConfirmed; + private boolean isFiatSent; + private boolean isFiatReceived; + private boolean isPayoutPublished; + private boolean isWithdrawn; + + public TradeInfoBuilder withOffer(OfferInfo offer) { + this.offer = offer; + return this; + } + + public TradeInfoBuilder withTradeId(String tradeId) { + this.tradeId = tradeId; + return this; + } + + public TradeInfoBuilder withShortId(String shortId) { + this.shortId = shortId; + return this; + } + + public TradeInfoBuilder withState(String state) { + this.state = state; + return this; + } + + public TradeInfoBuilder withPhase(String phase) { + this.phase = phase; + return this; + } + + public TradeInfoBuilder withTradePeriodState(String tradePeriodState) { + this.tradePeriodState = tradePeriodState; + return this; + } + + public TradeInfoBuilder withIsDepositPublished(boolean isDepositPublished) { + this.isDepositPublished = isDepositPublished; + return this; + } + + public TradeInfoBuilder withIsDepositConfirmed(boolean isDepositConfirmed) { + this.isDepositConfirmed = isDepositConfirmed; + return this; + } + + public TradeInfoBuilder withIsFiatSent(boolean isFiatSent) { + this.isFiatSent = isFiatSent; + return this; + } + + public TradeInfoBuilder withIsFiatReceived(boolean isFiatReceived) { + this.isFiatReceived = isFiatReceived; + return this; + } + + public TradeInfoBuilder withIsPayoutPublished(boolean isPayoutPublished) { + this.isPayoutPublished = isPayoutPublished; + return this; + } + + public TradeInfoBuilder withIsWithdrawn(boolean isWithdrawn) { + this.isWithdrawn = isWithdrawn; + return this; + } + + public TradeInfo build() { + return new TradeInfo(this); + } + } + + @Override + public String toString() { + return "TradeInfo{" + + " tradeId='" + tradeId + '\'' + "\n" + + ", shortId='" + shortId + '\'' + "\n" + + ", state='" + state + '\'' + "\n" + + ", phase='" + phase + '\'' + "\n" + + ", tradePeriodState='" + tradePeriodState + '\'' + "\n" + + ", isDepositPublished=" + isDepositPublished + "\n" + + ", isDepositConfirmed=" + isDepositConfirmed + "\n" + + ", isFiatSent=" + isFiatSent + "\n" + + ", isFiatReceived=" + isFiatReceived + "\n" + + ", isPayoutPublished=" + isPayoutPublished + "\n" + + ", isWithdrawn=" + isWithdrawn + "\n" + + ", offer=" + offer + "\n" + + '}'; + } +} diff --git a/core/src/main/java/bisq/core/offer/CreateOfferService.java b/core/src/main/java/bisq/core/offer/CreateOfferService.java index 0d2ae126beb..3c7d5ced9df 100644 --- a/core/src/main/java/bisq/core/offer/CreateOfferService.java +++ b/core/src/main/java/bisq/core/offer/CreateOfferService.java @@ -17,21 +17,16 @@ package bisq.core.offer; -import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.btc.TxFeeEstimationService; -import bisq.core.btc.wallet.BsqWalletService; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.Restrictions; -import bisq.core.filter.FilterManager; import bisq.core.locale.CurrencyUtil; import bisq.core.locale.Res; import bisq.core.monetary.Price; -import bisq.core.payment.HalCashAccount; import bisq.core.payment.PaymentAccount; import bisq.core.payment.PaymentAccountUtil; import bisq.core.provider.price.MarketPrice; import bisq.core.provider.price.PriceFeedService; -import bisq.core.trade.statistics.ReferralIdService; import bisq.core.user.Preferences; import bisq.core.user.User; import bisq.core.util.coin.CoinUtil; @@ -62,14 +57,9 @@ @Slf4j @Singleton public class CreateOfferService { + private final OfferUtil offerUtil; private final TxFeeEstimationService txFeeEstimationService; - private final MakerFeeProvider makerFeeProvider; - private final BsqWalletService bsqWalletService; - private final Preferences preferences; private final PriceFeedService priceFeedService; - private final AccountAgeWitnessService accountAgeWitnessService; - private final ReferralIdService referralIdService; - private final FilterManager filterManager; private final P2PService p2PService; private final PubKeyRing pubKeyRing; private final User user; @@ -81,26 +71,16 @@ public class CreateOfferService { /////////////////////////////////////////////////////////////////////////////////////////// @Inject - public CreateOfferService(TxFeeEstimationService txFeeEstimationService, - MakerFeeProvider makerFeeProvider, - BsqWalletService bsqWalletService, - Preferences preferences, + public CreateOfferService(OfferUtil offerUtil, + TxFeeEstimationService txFeeEstimationService, PriceFeedService priceFeedService, - AccountAgeWitnessService accountAgeWitnessService, - ReferralIdService referralIdService, - FilterManager filterManager, P2PService p2PService, PubKeyRing pubKeyRing, User user, BtcWalletService btcWalletService) { + this.offerUtil = offerUtil; this.txFeeEstimationService = txFeeEstimationService; - this.makerFeeProvider = makerFeeProvider; - this.bsqWalletService = bsqWalletService; - this.preferences = preferences; this.priceFeedService = priceFeedService; - this.accountAgeWitnessService = accountAgeWitnessService; - this.referralIdService = referralIdService; - this.filterManager = filterManager; this.p2PService = p2PService; this.pubKeyRing = pubKeyRing; this.user = user; @@ -161,7 +141,7 @@ public Offer createAndGetOffer(String offerId, NodeAddress makerAddress = p2PService.getAddress(); boolean useMarketBasedPriceValue = useMarketBasedPrice && isMarketPriceAvailable(currencyCode) && - !isHalCashAccount(paymentAccount); + !paymentAccount.isHalCashAccount(); long priceAsLong = price != null && !useMarketBasedPriceValue ? price.getValue() : 0L; double marketPriceMarginParam = useMarketBasedPriceValue ? marketPriceMargin : 0; @@ -185,11 +165,11 @@ public Offer createAndGetOffer(String offerId, double sellerSecurityDeposit = getSellerSecurityDepositAsDouble(buyerSecurityDepositAsDouble); Coin txFeeFromFeeService = getEstimatedFeeAndTxSize(amount, direction, buyerSecurityDepositAsDouble, sellerSecurityDeposit).first; Coin txFeeToUse = txFee.isPositive() ? txFee : txFeeFromFeeService; - Coin makerFeeAsCoin = getMakerFee(amount); - boolean isCurrencyForMakerFeeBtc = OfferUtil.isCurrencyForMakerFeeBtc(preferences, bsqWalletService, amount); + Coin makerFeeAsCoin = offerUtil.getMakerFee(amount); + boolean isCurrencyForMakerFeeBtc = offerUtil.isCurrencyForMakerFeeBtc(amount); Coin buyerSecurityDepositAsCoin = getBuyerSecurityDeposit(amount, buyerSecurityDepositAsDouble); Coin sellerSecurityDepositAsCoin = getSellerSecurityDeposit(amount, sellerSecurityDeposit); - long maxTradeLimit = getMaxTradeLimit(paymentAccount, currencyCode, direction); + long maxTradeLimit = offerUtil.getMaxTradeLimit(paymentAccount, currencyCode, direction); long maxTradePeriod = paymentAccount.getMaxTradePeriod(); // reserved for future use cases @@ -200,15 +180,11 @@ public Offer createAndGetOffer(String offerId, long lowerClosePrice = 0; long upperClosePrice = 0; String hashOfChallenge = null; - Map extraDataMap = OfferUtil.getExtraDataMap(accountAgeWitnessService, - referralIdService, - paymentAccount, + Map extraDataMap = offerUtil.getExtraDataMap(paymentAccount, currencyCode, - preferences, direction); - OfferUtil.validateOfferData(filterManager, - p2PService, + offerUtil.validateOfferData( buyerSecurityDepositAsDouble, paymentAccount, currencyCode, @@ -261,8 +237,12 @@ public Tuple2 getEstimatedFeeAndTxSize(Coin amount, OfferPayload.Direction direction, double buyerSecurityDeposit, double sellerSecurityDeposit) { - Coin reservedFundsForOffer = getReservedFundsForOffer(direction, amount, buyerSecurityDeposit, sellerSecurityDeposit); - return txFeeEstimationService.getEstimatedFeeAndTxSizeForMaker(reservedFundsForOffer, getMakerFee(amount)); + Coin reservedFundsForOffer = getReservedFundsForOffer(direction, + amount, + buyerSecurityDeposit, + sellerSecurityDeposit); + return txFeeEstimationService.getEstimatedFeeAndTxSizeForMaker(reservedFundsForOffer, + offerUtil.getMakerFee(amount)); } public Coin getReservedFundsForOffer(OfferPayload.Direction direction, @@ -274,7 +254,7 @@ public Coin getReservedFundsForOffer(OfferPayload.Direction direction, amount, buyerSecurityDeposit, sellerSecurityDeposit); - if (!isBuyOffer(direction)) + if (!offerUtil.isBuyOffer(direction)) reservedFundsForOffer = reservedFundsForOffer.add(amount); return reservedFundsForOffer; @@ -284,7 +264,7 @@ public Coin getSecurityDeposit(OfferPayload.Direction direction, Coin amount, double buyerSecurityDeposit, double sellerSecurityDeposit) { - return isBuyOffer(direction) ? + return offerUtil.isBuyOffer(direction) ? getBuyerSecurityDeposit(amount, buyerSecurityDeposit) : getSellerSecurityDeposit(amount, sellerSecurityDeposit); } @@ -294,25 +274,6 @@ public double getSellerSecurityDepositAsDouble(double buyerSecurityDeposit) { Restrictions.getSellerSecurityDepositAsPercent(); } - public Coin getMakerFee(Coin amount) { - return makerFeeProvider.getMakerFee(bsqWalletService, preferences, amount); - } - - public long getMaxTradeLimit(PaymentAccount paymentAccount, - String currencyCode, - OfferPayload.Direction direction) { - if (paymentAccount != null) { - return accountAgeWitnessService.getMyTradeLimit(paymentAccount, currencyCode, direction); - } else { - return 0; - } - } - - public boolean isBuyOffer(OfferPayload.Direction direction) { - return OfferUtil.isBuyOffer(direction); - } - - /////////////////////////////////////////////////////////////////////////////////////////// // Private /////////////////////////////////////////////////////////////////////////////////////////// @@ -322,20 +283,13 @@ private boolean isMarketPriceAvailable(String currencyCode) { return marketPrice != null && marketPrice.isExternallyProvidedPrice(); } - private boolean isHalCashAccount(PaymentAccount paymentAccount) { - return paymentAccount instanceof HalCashAccount; - } - private Coin getBuyerSecurityDeposit(Coin amount, double buyerSecurityDeposit) { Coin percentOfAmountAsCoin = CoinUtil.getPercentOfAmountAsCoin(buyerSecurityDeposit, amount); return getBoundedBuyerSecurityDeposit(percentOfAmountAsCoin); } private Coin getSellerSecurityDeposit(Coin amount, double sellerSecurityDeposit) { - Coin amountAsCoin = amount; - if (amountAsCoin == null) - amountAsCoin = Coin.ZERO; - + Coin amountAsCoin = (amount == null) ? Coin.ZERO : amount; Coin percentOfAmountAsCoin = CoinUtil.getPercentOfAmountAsCoin(sellerSecurityDeposit, amountAsCoin); return getBoundedSellerSecurityDeposit(percentOfAmountAsCoin); } diff --git a/core/src/main/java/bisq/core/offer/MakerFeeProvider.java b/core/src/main/java/bisq/core/offer/MakerFeeProvider.java deleted file mode 100644 index dc2d0fbd7f2..00000000000 --- a/core/src/main/java/bisq/core/offer/MakerFeeProvider.java +++ /dev/null @@ -1,29 +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.core.offer; - -import bisq.core.btc.wallet.BsqWalletService; -import bisq.core.user.Preferences; - -import org.bitcoinj.core.Coin; - -public class MakerFeeProvider { - public Coin getMakerFee(BsqWalletService bsqWalletService, Preferences preferences, Coin amount) { - return OfferUtil.getMakerFee(bsqWalletService, preferences, amount); - } -} diff --git a/core/src/main/java/bisq/core/offer/Offer.java b/core/src/main/java/bisq/core/offer/Offer.java index 93bab10b419..bcca0078b4e 100644 --- a/core/src/main/java/bisq/core/offer/Offer.java +++ b/core/src/main/java/bisq/core/offer/Offer.java @@ -27,6 +27,7 @@ import bisq.core.payment.payload.PaymentMethod; import bisq.core.provider.price.MarketPrice; import bisq.core.provider.price.PriceFeedService; +import bisq.core.util.VolumeUtil; import bisq.network.p2p.NodeAddress; @@ -96,13 +97,13 @@ public enum State { private final OfferPayload offerPayload; @JsonExclude @Getter - transient private ObjectProperty stateProperty = new SimpleObjectProperty<>(Offer.State.UNKNOWN); + final transient private ObjectProperty stateProperty = new SimpleObjectProperty<>(Offer.State.UNKNOWN); @JsonExclude @Nullable transient private OfferAvailabilityProtocol availabilityProtocol; @JsonExclude @Getter - transient private StringProperty errorMessageProperty = new SimpleStringProperty(); + final transient private StringProperty errorMessageProperty = new SimpleStringProperty(); @JsonExclude @Nullable @Setter @@ -231,9 +232,9 @@ public Volume getVolumeByAmount(Coin amount) { if (price != null && amount != null) { Volume volumeByAmount = price.getVolumeByAmount(amount); if (offerPayload.getPaymentMethodId().equals(PaymentMethod.HAL_CASH_ID)) - volumeByAmount = OfferUtil.getAdjustedVolumeForHalCash(volumeByAmount); + volumeByAmount = VolumeUtil.getAdjustedVolumeForHalCash(volumeByAmount); else if (CurrencyUtil.isFiatCurrency(offerPayload.getCurrencyCode())) - volumeByAmount = OfferUtil.getRoundedFiatVolume(volumeByAmount); + volumeByAmount = VolumeUtil.getRoundedFiatVolume(volumeByAmount); return volumeByAmount; } else { diff --git a/core/src/main/java/bisq/core/offer/OfferUtil.java b/core/src/main/java/bisq/core/offer/OfferUtil.java index d5c47ae473d..373df679073 100644 --- a/core/src/main/java/bisq/core/offer/OfferUtil.java +++ b/core/src/main/java/bisq/core/offer/OfferUtil.java @@ -19,7 +19,6 @@ import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.btc.wallet.BsqWalletService; -import bisq.core.btc.wallet.Restrictions; import bisq.core.filter.FilterManager; import bisq.core.locale.CurrencyUtil; import bisq.core.locale.Res; @@ -44,7 +43,8 @@ import org.bitcoinj.core.Coin; import org.bitcoinj.utils.Fiat; -import com.google.common.annotations.VisibleForTesting; +import javax.inject.Inject; +import javax.inject.Singleton; import java.util.HashMap; import java.util.Map; @@ -54,95 +54,174 @@ import javax.annotation.Nullable; +import static bisq.common.util.MathUtils.roundDoubleToLong; +import static bisq.common.util.MathUtils.scaleUpByPowerOf10; +import static bisq.core.btc.wallet.Restrictions.getMaxBuyerSecurityDepositAsPercent; +import static bisq.core.btc.wallet.Restrictions.getMinBuyerSecurityDepositAsPercent; +import static bisq.core.btc.wallet.Restrictions.getMinNonDustOutput; +import static bisq.core.btc.wallet.Restrictions.isDust; +import static bisq.core.offer.OfferPayload.*; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; /** - * This class holds utility methods for the creation of an Offer. - * Most of these are extracted here because they are used both in the GUI and in the API. - *

- * Long-term there could be a GUI-agnostic OfferService which provides these and other functionality to both the - * GUI and the API. + * This class holds utility methods for creating, editing and taking an Offer. */ @Slf4j +@Singleton public class OfferUtil { + private final AccountAgeWitnessService accountAgeWitnessService; + private final BsqWalletService bsqWalletService; + private final FilterManager filterManager; + private final Preferences preferences; + private final PriceFeedService priceFeedService; + private final P2PService p2PService; + private final ReferralIdService referralIdService; + + @Inject + public OfferUtil(AccountAgeWitnessService accountAgeWitnessService, + BsqWalletService bsqWalletService, + FilterManager filterManager, + Preferences preferences, + PriceFeedService priceFeedService, + P2PService p2PService, + ReferralIdService referralIdService) { + this.accountAgeWitnessService = accountAgeWitnessService; + this.bsqWalletService = bsqWalletService; + this.filterManager = filterManager; + this.preferences = preferences; + this.priceFeedService = priceFeedService; + this.p2PService = p2PService; + this.referralIdService = referralIdService; + } + /** * Given the direction, is this a BUY? * * @param direction the offer direction - * @return {@code true} for an offer to buy BTC from the taker, {@code false} for an offer to sell BTC to the taker + * @return {@code true} for an offer to buy BTC from the taker, {@code false} for an + * offer to sell BTC to the taker */ - public static boolean isBuyOffer(OfferPayload.Direction direction) { - return direction == OfferPayload.Direction.BUY; + public boolean isBuyOffer(Direction direction) { + return direction == Direction.BUY; + } + + public long getMaxTradeLimit(PaymentAccount paymentAccount, + String currencyCode, + Direction direction) { + return paymentAccount != null + ? accountAgeWitnessService.getMyTradeLimit(paymentAccount, currencyCode, direction) + : 0; } /** - * Returns the makerFee as Coin, this can be priced in BTC or BSQ. + * Return true if a balance can cover a cost. * - * @param bsqWalletService wallet service used to check if there is enough BSQ to pay the fee - * @param preferences preferences are used to see if the user indicated a preference for paying fees in BTC - * @param amount the amount of BTC to trade - * @return the maker fee for the given trade amount, or {@code null} if the amount is {@code null} + * @param cost the cost of a trade + * @param balance a wallet balance + * @return true if balance >= cost */ - @Nullable - public static Coin getMakerFee(BsqWalletService bsqWalletService, Preferences preferences, @Nullable Coin amount) { - boolean isCurrencyForMakerFeeBtc = isCurrencyForMakerFeeBtc(preferences, bsqWalletService, amount); - return getMakerFee(isCurrencyForMakerFeeBtc, amount); + public boolean isBalanceSufficient(Coin cost, Coin balance) { + return cost != null && balance.compareTo(cost) >= 0; } /** - * Calculates the maker fee for the given amount, marketPrice and marketPriceMargin. + * Return the wallet balance shortage for a given trade cost, or zero if there is + * no shortage. * - * @param isCurrencyForMakerFeeBtc {@code true} to pay fee in BTC, {@code false} to pay fee in BSQ - * @param amount the amount of BTC to trade - * @return the maker fee for the given trade amount, or {@code null} if the amount is {@code null} + * @param cost the cost of a trade + * @param balance a wallet balance + * @return the wallet balance shortage for the given cost, else zero. */ - @Nullable - public static Coin getMakerFee(boolean isCurrencyForMakerFeeBtc, @Nullable Coin amount) { - if (amount != null) { - Coin feePerBtc = CoinUtil.getFeePerBtc(FeeService.getMakerFeePerBtc(isCurrencyForMakerFeeBtc), amount); - return CoinUtil.maxCoin(feePerBtc, FeeService.getMinMakerFee(isCurrencyForMakerFeeBtc)); + public Coin getBalanceShortage(Coin cost, Coin balance) { + if (cost != null) { + Coin shortage = cost.subtract(balance); + return shortage.isNegative() ? Coin.ZERO : shortage; } else { - return null; + return Coin.ZERO; } } /** - * Checks if the maker fee should be paid in BTC, this can be the case due to user preference or because the user - * doesn't have enough BSQ. + * Returns the usable BSQ balance. + * + * @return Coin the usable BSQ balance + */ + public Coin getUsableBsqBalance() { + // We have to keep a minimum amount of BSQ == bitcoin dust limit, otherwise there + // would be dust violations for change UTXOs; essentially means the minimum usable + // balance of BSQ is 5.46. + Coin usableBsqBalance = bsqWalletService.getAvailableConfirmedBalance().subtract(getMinNonDustOutput()); + return usableBsqBalance.isNegative() ? Coin.ZERO : usableBsqBalance; + } + + public double calculateManualPrice(double volumeAsDouble, double amountAsDouble) { + return volumeAsDouble / amountAsDouble; + } + + public double calculateMarketPriceMargin(double manualPrice, double marketPrice) { + return MathUtils.roundDouble(manualPrice / marketPrice, 4); + } + + /** + * Returns the makerFee as Coin, this can be priced in BTC or BSQ. * - * @param preferences preferences are used to see if the user indicated a preference for paying fees in BTC - * @param bsqWalletService wallet service used to check if there is enough BSQ to pay the fee * @param amount the amount of BTC to trade - * @return {@code true} if BTC is preferred or the trade amount is nonnull and there isn't enough BSQ for it + * @return the maker fee for the given trade amount, or {@code null} if the amount + * is {@code null} */ - public static boolean isCurrencyForMakerFeeBtc(Preferences preferences, - BsqWalletService bsqWalletService, - @Nullable Coin amount) { + @Nullable + public Coin getMakerFee(@Nullable Coin amount) { + boolean isCurrencyForMakerFeeBtc = isCurrencyForMakerFeeBtc(amount); + return CoinUtil.getMakerFee(isCurrencyForMakerFeeBtc, amount); + } + + public Coin getTxFeeBySize(Coin txFeePerByteFromFeeService, int sizeInBytes) { + return txFeePerByteFromFeeService.multiply(getAverageTakerFeeTxSize(sizeInBytes)); + } + + // We use the sum of the size of the trade fee and the deposit tx to get an average. + // Miners will take the trade fee tx if the total fee of both dependent txs are good + // enough. With that we avoid that we overpay in case that the trade fee has many + // inputs and we would apply that fee for the other 2 txs as well. We still might + // overpay a bit for the payout tx. + public int getAverageTakerFeeTxSize(int txSize) { + return (txSize + 320) / 2; + } + + /** + * Checks if the maker fee should be paid in BTC, this can be the case due to user + * preference or because the user doesn't have enough BSQ. + * + * @param amount the amount of BTC to trade + * @return {@code true} if BTC is preferred or the trade amount is nonnull and there + * isn't enough BSQ for it. + */ + public boolean isCurrencyForMakerFeeBtc(@Nullable Coin amount) { boolean payFeeInBtc = preferences.getPayFeeInBtc(); - boolean bsqForFeeAvailable = isBsqForMakerFeeAvailable(bsqWalletService, amount); + boolean bsqForFeeAvailable = isBsqForMakerFeeAvailable(amount); return payFeeInBtc || !bsqForFeeAvailable; } /** * Checks if the available BSQ balance is sufficient to pay for the offer's maker fee. * - * @param bsqWalletService wallet service used to check if there is enough BSQ to pay the fee * @param amount the amount of BTC to trade * @return {@code true} if the balance is sufficient, {@code false} otherwise */ - public static boolean isBsqForMakerFeeAvailable(BsqWalletService bsqWalletService, @Nullable Coin amount) { + public boolean isBsqForMakerFeeAvailable(@Nullable Coin amount) { Coin availableBalance = bsqWalletService.getAvailableConfirmedBalance(); - Coin makerFee = getMakerFee(false, amount); + Coin makerFee = CoinUtil.getMakerFee(false, amount); - // If we don't know yet the maker fee (amount is not set) we return true, otherwise we would disable BSQ - // fee each time we open the create offer screen as there the amount is not set. + // If we don't know yet the maker fee (amount is not set) we return true, + // otherwise we would disable BSQ fee each time we open the create offer screen + // as there the amount is not set. if (makerFee == null) return true; Coin surplusFunds = availableBalance.subtract(makerFee); - if (Restrictions.isDust(surplusFunds)) { + if (isDust(surplusFunds)) { return false; // we can't be left with dust } return !availableBalance.subtract(makerFee).isNegative(); @@ -150,7 +229,7 @@ public static boolean isBsqForMakerFeeAvailable(BsqWalletService bsqWalletServic @Nullable - public static Coin getTakerFee(boolean isCurrencyForTakerFeeBtc, @Nullable Coin amount) { + public Coin getTakerFee(boolean isCurrencyForTakerFeeBtc, @Nullable Coin amount) { if (amount != null) { Coin feePerBtc = CoinUtil.getFeePerBtc(FeeService.getTakerFeePerBtc(isCurrencyForTakerFeeBtc), amount); return CoinUtil.maxCoin(feePerBtc, FeeService.getMinTakerFee(isCurrencyForTakerFeeBtc)); @@ -159,238 +238,117 @@ public static Coin getTakerFee(boolean isCurrencyForTakerFeeBtc, @Nullable Coin } } - public static boolean isCurrencyForTakerFeeBtc(Preferences preferences, - BsqWalletService bsqWalletService, - Coin amount) { + public boolean isCurrencyForTakerFeeBtc(Coin amount) { boolean payFeeInBtc = preferences.getPayFeeInBtc(); - boolean bsqForFeeAvailable = isBsqForTakerFeeAvailable(bsqWalletService, amount); + boolean bsqForFeeAvailable = isBsqForTakerFeeAvailable(amount); return payFeeInBtc || !bsqForFeeAvailable; } - public static boolean isBsqForTakerFeeAvailable(BsqWalletService bsqWalletService, @Nullable Coin amount) { + public boolean isBsqForTakerFeeAvailable(@Nullable Coin amount) { Coin availableBalance = bsqWalletService.getAvailableConfirmedBalance(); Coin takerFee = getTakerFee(false, amount); - // If we don't know yet the maker fee (amount is not set) we return true, otherwise we would disable BSQ - // fee each time we open the create offer screen as there the amount is not set. + // If we don't know yet the maker fee (amount is not set) we return true, + // otherwise we would disable BSQ fee each time we open the create offer screen + // as there the amount is not set. if (takerFee == null) return true; Coin surplusFunds = availableBalance.subtract(takerFee); - if (Restrictions.isDust(surplusFunds)) { + if (isDust(surplusFunds)) { return false; // we can't be left with dust } return !availableBalance.subtract(takerFee).isNegative(); } - public static Volume getRoundedFiatVolume(Volume volumeByAmount) { - // We want to get rounded to 1 unit of the fiat currency, e.g. 1 EUR. - return getAdjustedFiatVolume(volumeByAmount, 1); - } - - public static Volume getAdjustedVolumeForHalCash(Volume volumeByAmount) { - // EUR has precision 4 and we want multiple of 10 so we divide by 100000 then - // round and multiply with 10 - return getAdjustedFiatVolume(volumeByAmount, 10); - } - - /** - * - * @param volumeByAmount The volume generated from an amount - * @param factor The factor used for rounding. E.g. 1 means rounded to units of 1 EUR, 10 means rounded to 10 EUR... - * @return The adjusted Fiat volume - */ - @VisibleForTesting - static Volume getAdjustedFiatVolume(Volume volumeByAmount, int factor) { - // Fiat currencies use precision 4 and we want multiple of factor so we divide by 10000 * factor then - // round and multiply with factor - long roundedVolume = Math.round((double) volumeByAmount.getValue() / (10000d * factor)) * factor; - // Smallest allowed volume is factor (e.g. 10 EUR or 1 EUR,...) - roundedVolume = Math.max(factor, roundedVolume); - return Volume.parse(String.valueOf(roundedVolume), volumeByAmount.getCurrencyCode()); - } - - /** - * Calculate the possibly adjusted amount for {@code amount}, taking into account the - * {@code price} and {@code maxTradeLimit} and {@code factor}. - * - * @param amount Bitcoin amount which is a candidate for getting rounded. - * @param price Price used in relation to that amount. - * @param maxTradeLimit The max. trade limit of the users account, in satoshis. - * @return The adjusted amount - */ - public static Coin getRoundedFiatAmount(Coin amount, Price price, long maxTradeLimit) { - return getAdjustedAmount(amount, price, maxTradeLimit, 1); - } - - public static Coin getAdjustedAmountForHalCash(Coin amount, Price price, long maxTradeLimit) { - return getAdjustedAmount(amount, price, maxTradeLimit, 10); - } - - /** - * Calculate the possibly adjusted amount for {@code amount}, taking into account the - * {@code price} and {@code maxTradeLimit} and {@code factor}. - * - * @param amount Bitcoin amount which is a candidate for getting rounded. - * @param price Price used in relation to that amount. - * @param maxTradeLimit The max. trade limit of the users account, in satoshis. - * @param factor The factor used for rounding. E.g. 1 means rounded to units of - * 1 EUR, 10 means rounded to 10 EUR, etc. - * @return The adjusted amount - */ - @VisibleForTesting - static Coin getAdjustedAmount(Coin amount, Price price, long maxTradeLimit, int factor) { - checkArgument( - amount.getValue() >= 10_000, - "amount needs to be above minimum of 10k satoshis" - ); - checkArgument( - factor > 0, - "factor needs to be positive" - ); - // Amount must result in a volume of min factor units of the fiat currency, e.g. 1 EUR or - // 10 EUR in case of HalCash. - Volume smallestUnitForVolume = Volume.parse(String.valueOf(factor), price.getCurrencyCode()); - if (smallestUnitForVolume.getValue() <= 0) - return Coin.ZERO; - - Coin smallestUnitForAmount = price.getAmountByVolume(smallestUnitForVolume); - long minTradeAmount = Restrictions.getMinTradeAmount().value; - - // We use 10 000 satoshi as min allowed amount - checkArgument( - minTradeAmount >= 10_000, - "MinTradeAmount must be at least 10k satoshis" - ); - smallestUnitForAmount = Coin.valueOf(Math.max(minTradeAmount, smallestUnitForAmount.value)); - // We don't allow smaller amount values than smallestUnitForAmount - if (amount.compareTo(smallestUnitForAmount) < 0) - amount = smallestUnitForAmount; - - // We get the adjusted volume from our amount - Volume volume = getAdjustedFiatVolume(price.getVolumeByAmount(amount), factor); - if (volume.getValue() <= 0) - return Coin.ZERO; - - // From that adjusted volume we calculate back the amount. It might be a bit different as - // the amount used as input before due rounding. - amount = price.getAmountByVolume(volume); - - // For the amount we allow only 4 decimal places - long adjustedAmount = Math.round((double) amount.value / 10000d) * 10000; - - // If we are above our trade limit we reduce the amount by the smallestUnitForAmount - while (adjustedAmount > maxTradeLimit) { - adjustedAmount -= smallestUnitForAmount.value; - } - adjustedAmount = Math.max(minTradeAmount, adjustedAmount); - adjustedAmount = Math.min(maxTradeLimit, adjustedAmount); - return Coin.valueOf(adjustedAmount); - } - - public static Optional getFeeInUserFiatCurrency(Coin makerFee, boolean isCurrencyForMakerFeeBtc, - Preferences preferences, PriceFeedService priceFeedService, - CoinFormatter bsqFormatter) { + public Optional getFeeInUserFiatCurrency(Coin makerFee, + boolean isCurrencyForMakerFeeBtc, + CoinFormatter bsqFormatter) { String countryCode = preferences.getUserCountry().code; String userCurrencyCode = CurrencyUtil.getCurrencyByCountryCode(countryCode).getCode(); return getFeeInUserFiatCurrency(makerFee, isCurrencyForMakerFeeBtc, userCurrencyCode, - priceFeedService, bsqFormatter); } - private static Optional getFeeInUserFiatCurrency(Coin makerFee, boolean isCurrencyForMakerFeeBtc, - String userCurrencyCode, PriceFeedService priceFeedService, - CoinFormatter bsqFormatter) { - // We use the users currency derived from his selected country. - // We don't use the preferredTradeCurrency from preferences as that can be also set to an altcoin. - - MarketPrice marketPrice = priceFeedService.getMarketPrice(userCurrencyCode); - if (marketPrice != null && makerFee != null) { - long marketPriceAsLong = MathUtils.roundDoubleToLong(MathUtils.scaleUpByPowerOf10(marketPrice.getPrice(), Fiat.SMALLEST_UNIT_EXPONENT)); - Price userCurrencyPrice = Price.valueOf(userCurrencyCode, marketPriceAsLong); - - if (isCurrencyForMakerFeeBtc) { - return Optional.of(userCurrencyPrice.getVolumeByAmount(makerFee)); - } else { - Optional optionalBsqPrice = priceFeedService.getBsqPrice(); - if (optionalBsqPrice.isPresent()) { - Price bsqPrice = optionalBsqPrice.get(); - String inputValue = bsqFormatter.formatCoin(makerFee); - Volume makerFeeAsVolume = Volume.parse(inputValue, "BSQ"); - Coin requiredBtc = bsqPrice.getAmountByVolume(makerFeeAsVolume); - return Optional.of(userCurrencyPrice.getVolumeByAmount(requiredBtc)); - } else { - return Optional.empty(); - } - } - } else { - return Optional.empty(); - } - } - - - public static Map getExtraDataMap(AccountAgeWitnessService accountAgeWitnessService, - ReferralIdService referralIdService, - PaymentAccount paymentAccount, - String currencyCode, - Preferences preferences, - OfferPayload.Direction direction) { + public Map getExtraDataMap(PaymentAccount paymentAccount, + String currencyCode, + Direction direction) { Map extraDataMap = new HashMap<>(); if (CurrencyUtil.isFiatCurrency(currencyCode)) { - String myWitnessHashAsHex = accountAgeWitnessService.getMyWitnessHashAsHex(paymentAccount.getPaymentAccountPayload()); - extraDataMap.put(OfferPayload.ACCOUNT_AGE_WITNESS_HASH, myWitnessHashAsHex); + String myWitnessHashAsHex = accountAgeWitnessService + .getMyWitnessHashAsHex(paymentAccount.getPaymentAccountPayload()); + extraDataMap.put(ACCOUNT_AGE_WITNESS_HASH, myWitnessHashAsHex); } if (referralIdService.getOptionalReferralId().isPresent()) { - extraDataMap.put(OfferPayload.REFERRAL_ID, referralIdService.getOptionalReferralId().get()); + extraDataMap.put(REFERRAL_ID, referralIdService.getOptionalReferralId().get()); } if (paymentAccount instanceof F2FAccount) { - extraDataMap.put(OfferPayload.F2F_CITY, ((F2FAccount) paymentAccount).getCity()); - extraDataMap.put(OfferPayload.F2F_EXTRA_INFO, ((F2FAccount) paymentAccount).getExtraInfo()); + extraDataMap.put(F2F_CITY, ((F2FAccount) paymentAccount).getCity()); + extraDataMap.put(F2F_EXTRA_INFO, ((F2FAccount) paymentAccount).getExtraInfo()); } - extraDataMap.put(OfferPayload.CAPABILITIES, Capabilities.app.toStringList()); + extraDataMap.put(CAPABILITIES, Capabilities.app.toStringList()); - if (currencyCode.equals("XMR") && direction == OfferPayload.Direction.SELL) { + if (currencyCode.equals("XMR") && direction == Direction.SELL) { preferences.getAutoConfirmSettingsList().stream() .filter(e -> e.getCurrencyCode().equals("XMR")) .filter(AutoConfirmSettings::isEnabled) - .forEach(e -> extraDataMap.put(OfferPayload.XMR_AUTO_CONF, OfferPayload.XMR_AUTO_CONF_ENABLED_VALUE)); + .forEach(e -> extraDataMap.put(XMR_AUTO_CONF, XMR_AUTO_CONF_ENABLED_VALUE)); } return extraDataMap.isEmpty() ? null : extraDataMap; } - public static void validateOfferData(FilterManager filterManager, - P2PService p2PService, - double buyerSecurityDeposit, - PaymentAccount paymentAccount, - String currencyCode, - Coin makerFeeAsCoin) { + public void validateOfferData(double buyerSecurityDeposit, + PaymentAccount paymentAccount, + String currencyCode, + Coin makerFeeAsCoin) { checkNotNull(makerFeeAsCoin, "makerFee must not be null"); checkNotNull(p2PService.getAddress(), "Address must not be null"); - checkArgument(buyerSecurityDeposit <= Restrictions.getMaxBuyerSecurityDepositAsPercent(), + checkArgument(buyerSecurityDeposit <= getMaxBuyerSecurityDepositAsPercent(), "securityDeposit must not exceed " + - Restrictions.getMaxBuyerSecurityDepositAsPercent()); - checkArgument(buyerSecurityDeposit >= Restrictions.getMinBuyerSecurityDepositAsPercent(), + getMaxBuyerSecurityDepositAsPercent()); + checkArgument(buyerSecurityDeposit >= getMinBuyerSecurityDepositAsPercent(), "securityDeposit must not be less than " + - Restrictions.getMinBuyerSecurityDepositAsPercent()); + getMinBuyerSecurityDepositAsPercent()); checkArgument(!filterManager.isCurrencyBanned(currencyCode), Res.get("offerbook.warning.currencyBanned")); checkArgument(!filterManager.isPaymentMethodBanned(paymentAccount.getPaymentMethod()), Res.get("offerbook.warning.paymentMethodBanned")); } - // TODO no code duplication found in UI code (added for API) - /* public static Coin getFundsNeededForOffer(Coin tradeAmount, Coin buyerSecurityDeposit, OfferPayload.Direction direction) { - boolean buyOffer = isBuyOffer(direction); - Coin needed = buyOffer ? buyerSecurityDeposit : Restrictions.getSellerSecurityDeposit(); - if (!buyOffer) - needed = needed.add(tradeAmount); + private Optional getFeeInUserFiatCurrency(Coin makerFee, + boolean isCurrencyForMakerFeeBtc, + String userCurrencyCode, + CoinFormatter bsqFormatter) { + // We use the users currency derived from his selected country. We don't use the + // preferredTradeCurrency from preferences as that can be also set to an altcoin. + MarketPrice marketPrice = priceFeedService.getMarketPrice(userCurrencyCode); + if (marketPrice != null && makerFee != null) { + long marketPriceAsLong = roundDoubleToLong( + scaleUpByPowerOf10(marketPrice.getPrice(), Fiat.SMALLEST_UNIT_EXPONENT)); + Price userCurrencyPrice = Price.valueOf(userCurrencyCode, marketPriceAsLong); - return needed; - }*/ + if (isCurrencyForMakerFeeBtc) { + return Optional.of(userCurrencyPrice.getVolumeByAmount(makerFee)); + } else { + Optional optionalBsqPrice = priceFeedService.getBsqPrice(); + if (optionalBsqPrice.isPresent()) { + Price bsqPrice = optionalBsqPrice.get(); + String inputValue = bsqFormatter.formatCoin(makerFee); + Volume makerFeeAsVolume = Volume.parse(inputValue, "BSQ"); + Coin requiredBtc = bsqPrice.getAmountByVolume(makerFeeAsVolume); + return Optional.of(userCurrencyPrice.getVolumeByAmount(requiredBtc)); + } else { + return Optional.empty(); + } + } + } else { + return Optional.empty(); + } + } } diff --git a/core/src/main/java/bisq/core/offer/takeoffer/TakeOfferModel.java b/core/src/main/java/bisq/core/offer/takeoffer/TakeOfferModel.java new file mode 100644 index 00000000000..efd0523ec19 --- /dev/null +++ b/core/src/main/java/bisq/core/offer/takeoffer/TakeOfferModel.java @@ -0,0 +1,291 @@ +package bisq.core.offer.takeoffer; + +import bisq.core.account.witness.AccountAgeWitnessService; +import bisq.core.btc.model.AddressEntry; +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.locale.CurrencyUtil; +import bisq.core.monetary.Price; +import bisq.core.monetary.Volume; +import bisq.core.offer.Offer; +import bisq.core.offer.OfferUtil; +import bisq.core.payment.PaymentAccount; +import bisq.core.payment.payload.PaymentMethod; +import bisq.core.provider.fee.FeeService; +import bisq.core.provider.price.PriceFeedService; + +import bisq.common.taskrunner.Model; + +import org.bitcoinj.core.Coin; + +import javax.inject.Inject; + +import java.util.Objects; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import org.jetbrains.annotations.NotNull; + +import static bisq.core.btc.model.AddressEntry.Context.OFFER_FUNDING; +import static bisq.core.offer.OfferPayload.Direction.SELL; +import static bisq.core.util.VolumeUtil.getAdjustedVolumeForHalCash; +import static bisq.core.util.VolumeUtil.getRoundedFiatVolume; +import static bisq.core.util.coin.CoinUtil.minCoin; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.bitcoinj.core.Coin.ZERO; +import static org.bitcoinj.core.Coin.valueOf; + +@Slf4j +public class TakeOfferModel implements Model { + // Immutable + private final AccountAgeWitnessService accountAgeWitnessService; + private final BtcWalletService btcWalletService; + private final FeeService feeService; + private final OfferUtil offerUtil; + private final PriceFeedService priceFeedService; + + // Mutable + @Getter + private AddressEntry addressEntry; + @Getter + private Coin amount; + @Getter + private boolean isCurrencyForTakerFeeBtc; + private Offer offer; + private PaymentAccount paymentAccount; + @Getter + private Coin securityDeposit; + private boolean useSavingsWallet; + + // 260 kb is typical trade fee tx size with 1 input, but trade tx (deposit + payout) + // are larger so we adjust to 320. + private final int feeTxSize = 320; + private Coin txFeePerByteFromFeeService; + @Getter + private Coin txFeeFromFeeService; + @Getter + private Coin takerFee; + @Getter + private Coin totalToPayAsCoin; + @Getter + private Coin missingCoin = ZERO; + @Getter + private Coin totalAvailableBalance; + @Getter + private Coin balance; + @Getter + private boolean isBtcWalletFunded; + @Getter + private Volume volume; + + @Inject + public TakeOfferModel(AccountAgeWitnessService accountAgeWitnessService, + BtcWalletService btcWalletService, + FeeService feeService, + OfferUtil offerUtil, + PriceFeedService priceFeedService) { + this.accountAgeWitnessService = accountAgeWitnessService; + this.btcWalletService = btcWalletService; + this.feeService = feeService; + this.offerUtil = offerUtil; + this.priceFeedService = priceFeedService; + } + + public void initModel(Offer offer, + PaymentAccount paymentAccount, + boolean useSavingsWallet) { + this.clearModel(); + this.offer = offer; + this.paymentAccount = paymentAccount; + this.addressEntry = btcWalletService.getOrCreateAddressEntry(offer.getId(), OFFER_FUNDING); + validateModelInputs(); + + this.useSavingsWallet = useSavingsWallet; + this.amount = valueOf(Math.min(offer.getAmount().value, getMaxTradeLimit())); + this.securityDeposit = offer.getDirection() == SELL + ? offer.getBuyerSecurityDeposit() + : offer.getSellerSecurityDeposit(); + this.isCurrencyForTakerFeeBtc = offerUtil.isCurrencyForTakerFeeBtc(amount); + this.takerFee = offerUtil.getTakerFee(isCurrencyForTakerFeeBtc, amount); + + calculateTxFees(); + calculateVolume(); + calculateTotalToPay(); + offer.resetState(); + + priceFeedService.setCurrencyCode(offer.getCurrencyCode()); + } + + @Override + public void onComplete() { + // empty + } + + private void calculateTxFees() { + // Taker pays 3 times the tx fee (taker fee, deposit, payout) because the mining + // fee might be different when maker created the offer and reserved his funds. + // Taker creates at least taker fee and deposit tx at nearly the same moment. + // Just the payout will be later and still could lead to issues if the required + // fee changed a lot in the meantime. using RBF and/or multiple batch-signed + // payout tx with different fees might be an option but RBF is not supported yet + // in BitcoinJ and batched txs would add more complexity to the trade protocol. + + // A typical trade fee tx has about 260 bytes (if one input). The trade txs has + // about 336-414 bytes. We use 320 as a average value. + + // Fee calculations: + // Trade fee tx: 260 bytes (1 input) + // Deposit tx: 336 bytes (1 MS output+ OP_RETURN) - 414 bytes + // (1 MS output + OP_RETURN + change in case of smaller trade amount) + // Payout tx: 371 bytes + // Disputed payout tx: 408 bytes + + // Request actual fees: + log.info("Start requestTxFee: txFeeFromFeeService={}", txFeeFromFeeService); + feeService.requestFees(() -> { + txFeePerByteFromFeeService = feeService.getTxFeePerByte(); + txFeeFromFeeService = offerUtil.getTxFeeBySize(txFeePerByteFromFeeService, feeTxSize); + }); + } + + private void calculateTotalToPay() { + // Taker pays 2 times the tx fee because the mining fee might be different when + // maker created the offer and reserved his funds, so that would not work well + // with dynamic fees. The mining fee for the takeOfferFee tx is deducted from + // the createOfferFee and not visible to the trader. + Coin feeAndSecDeposit = getTotalTxFee().add(securityDeposit); + if (isCurrencyForTakerFeeBtc) + feeAndSecDeposit = feeAndSecDeposit.add(takerFee); + + totalToPayAsCoin = offer.isBuyOffer() + ? feeAndSecDeposit.add(amount) + : feeAndSecDeposit; + + updateBalance(); + } + + private void calculateVolume() { + Price tradePrice = offer.getPrice(); + Volume volumeByAmount = Objects.requireNonNull(tradePrice).getVolumeByAmount(amount); + + if (offer.getPaymentMethod().getId().equals(PaymentMethod.HAL_CASH_ID)) + volumeByAmount = getAdjustedVolumeForHalCash(volumeByAmount); + else if (CurrencyUtil.isFiatCurrency(offer.getCurrencyCode())) + volumeByAmount = getRoundedFiatVolume(volumeByAmount); + + volume = volumeByAmount; + + updateBalance(); + } + + private void updateBalance() { + Coin tradeWalletBalance = btcWalletService.getBalanceForAddress(addressEntry.getAddress()); + if (useSavingsWallet) { + Coin savingWalletBalance = btcWalletService.getSavingWalletBalance(); + totalAvailableBalance = savingWalletBalance.add(tradeWalletBalance); + if (totalToPayAsCoin != null) + balance = minCoin(totalToPayAsCoin, totalAvailableBalance); + + } else { + balance = tradeWalletBalance; + } + missingCoin = offerUtil.getBalanceShortage(totalToPayAsCoin, balance); + isBtcWalletFunded = offerUtil.isBalanceSufficient(totalToPayAsCoin, balance); + } + + private long getMaxTradeLimit() { + return accountAgeWitnessService.getMyTradeLimit(paymentAccount, + offer.getCurrencyCode(), + offer.getMirroredDirection()); + } + + public Coin getTotalTxFee() { + Coin totalTxFees = txFeeFromFeeService.add(getTxFeeForDepositTx()).add(getTxFeeForPayoutTx()); + if (isCurrencyForTakerFeeBtc) + return totalTxFees; + else + return totalTxFees.subtract(takerFee); + } + + @NotNull + public Coin getFundsNeededForTrade() { + // If taking a buy offer, taker needs to reserve the offer.amt too. + return securityDeposit + .add(getTxFeeForDepositTx()) + .add(getTxFeeForPayoutTx()) + .add(offer.isBuyOffer() ? amount : ZERO); + } + + private Coin getTxFeeForDepositTx() { + // TODO fix with new trade protocol! + // Unfortunately we cannot change that to the correct fees as it would break + // backward compatibility. We still might find a way with offer version or app + // version checks so lets keep that commented out code as that shows how it + // should be. + return txFeeFromFeeService; + } + + private Coin getTxFeeForPayoutTx() { + // TODO fix with new trade protocol! + // Unfortunately we cannot change that to the correct fees as it would break + // backward compatibility. We still might find a way with offer version or app + // version checks so lets keep that commented out code as that shows how it + // should be. + return txFeeFromFeeService; + } + + private void validateModelInputs() { + checkNotNull(offer, "offer must not be null"); + checkNotNull(offer.getAmount(), "offer amount must not be null"); + checkArgument(offer.getAmount().value > 0, "offer amount must not be zero"); + checkNotNull(offer.getPrice(), "offer price must not be null"); + checkNotNull(paymentAccount, "payment account must not be null"); + checkNotNull(addressEntry, "address entry must not be null"); + } + + private void clearModel() { + this.addressEntry = null; + this.amount = null; + this.balance = null; + this.isBtcWalletFunded = false; + this.isCurrencyForTakerFeeBtc = false; + this.missingCoin = ZERO; + this.offer = null; + this.paymentAccount = null; + this.securityDeposit = null; + this.takerFee = null; + this.totalAvailableBalance = null; + this.totalToPayAsCoin = null; + this.txFeeFromFeeService = null; + this.txFeePerByteFromFeeService = null; + this.useSavingsWallet = true; + this.volume = null; + } + + @Override + public String toString() { + return "TakeOfferModel{" + + " offer.id=" + offer.getId() + "\n" + + " offer.state=" + offer.getState() + "\n" + + ", paymentAccount.id=" + paymentAccount.getId() + "\n" + + ", paymentAccount.method.id=" + paymentAccount.getPaymentMethod().getId() + "\n" + + ", useSavingsWallet=" + useSavingsWallet + "\n" + + ", addressEntry=" + addressEntry + "\n" + + ", amount=" + amount + "\n" + + ", securityDeposit=" + securityDeposit + "\n" + + ", feeTxSize=" + feeTxSize + "\n" + + ", txFeePerByteFromFeeService=" + txFeePerByteFromFeeService + "\n" + + ", txFeeFromFeeService=" + txFeeFromFeeService + "\n" + + ", takerFee=" + takerFee + "\n" + + ", totalToPayAsCoin=" + totalToPayAsCoin + "\n" + + ", missingCoin=" + missingCoin + "\n" + + ", totalAvailableBalance=" + totalAvailableBalance + "\n" + + ", balance=" + balance + "\n" + + ", volume=" + volume + "\n" + + ", fundsNeededForTrade=" + getFundsNeededForTrade() + "\n" + + ", isCurrencyForTakerFeeBtc=" + isCurrencyForTakerFeeBtc + "\n" + + ", isBtcWalletFunded=" + isBtcWalletFunded + "\n" + + '}'; + } +} diff --git a/core/src/main/java/bisq/core/payment/PaymentAccount.java b/core/src/main/java/bisq/core/payment/PaymentAccount.java index 12a9565b710..b38649ef942 100644 --- a/core/src/main/java/bisq/core/payment/PaymentAccount.java +++ b/core/src/main/java/bisq/core/payment/PaymentAccount.java @@ -126,8 +126,7 @@ public void addCurrency(TradeCurrency tradeCurrency) { } public void removeCurrency(TradeCurrency tradeCurrency) { - if (tradeCurrencies.contains(tradeCurrency)) - tradeCurrencies.remove(tradeCurrency); + tradeCurrencies.remove(tradeCurrency); } public boolean hasMultipleCurrencies() { @@ -174,6 +173,30 @@ public String getOwnerId() { return paymentAccountPayload.getOwnerId(); } + public boolean isHalCashAccount() { + return this instanceof HalCashAccount; + } + + /** + * Return an Optional of the trade currency for this payment account, or + * Optional.empty() if none is found. If this payment account has a selected + * trade currency, that is returned, else its single trade currency is returned, + * else the first trade currency in the this payment account's tradeCurrencies + * list is returned. + * + * @return Optional of the trade currency for the given payment account + */ + public Optional getTradeCurrency() { + if (this.getSelectedTradeCurrency() != null) + return Optional.of(this.getSelectedTradeCurrency()); + else if (this.getSingleTradeCurrency() != null) + return Optional.of(this.getSingleTradeCurrency()); + else if (!this.getTradeCurrencies().isEmpty()) + return Optional.of(this.getTradeCurrencies().get(0)); + else + return Optional.empty(); + } + public void onAddToUser() { // We are in the process to get added to the user. This is called just before saving the account and the // last moment we could apply some special handling if needed (e.g. as it happens for Revolut) diff --git a/core/src/main/java/bisq/core/trade/Contract.java b/core/src/main/java/bisq/core/trade/Contract.java index 174719b0651..81602d226d0 100644 --- a/core/src/main/java/bisq/core/trade/Contract.java +++ b/core/src/main/java/bisq/core/trade/Contract.java @@ -21,10 +21,10 @@ import bisq.core.monetary.Price; import bisq.core.monetary.Volume; import bisq.core.offer.OfferPayload; -import bisq.core.offer.OfferUtil; import bisq.core.payment.payload.PaymentAccountPayload; import bisq.core.payment.payload.PaymentMethod; import bisq.core.proto.CoreProtoResolver; +import bisq.core.util.VolumeUtil; import bisq.network.p2p.NodeAddress; @@ -231,9 +231,9 @@ public Volume getTradeVolume() { Volume volumeByAmount = getTradePrice().getVolumeByAmount(getTradeAmount()); if (getPaymentMethodId().equals(PaymentMethod.HAL_CASH_ID)) - volumeByAmount = OfferUtil.getAdjustedVolumeForHalCash(volumeByAmount); + volumeByAmount = VolumeUtil.getAdjustedVolumeForHalCash(volumeByAmount); else if (CurrencyUtil.isFiatCurrency(getOfferPayload().getCurrencyCode())) - volumeByAmount = OfferUtil.getRoundedFiatVolume(volumeByAmount); + volumeByAmount = VolumeUtil.getRoundedFiatVolume(volumeByAmount); return volumeByAmount; } diff --git a/core/src/main/java/bisq/core/trade/Trade.java b/core/src/main/java/bisq/core/trade/Trade.java index bb73bc6ea96..a6f2d56ab99 100644 --- a/core/src/main/java/bisq/core/trade/Trade.java +++ b/core/src/main/java/bisq/core/trade/Trade.java @@ -22,7 +22,6 @@ import bisq.core.monetary.Price; import bisq.core.monetary.Volume; import bisq.core.offer.Offer; -import bisq.core.offer.OfferUtil; import bisq.core.payment.payload.PaymentMethod; import bisq.core.proto.CoreProtoResolver; import bisq.core.support.dispute.arbitration.arbitrator.Arbitrator; @@ -32,6 +31,7 @@ import bisq.core.trade.protocol.ProcessModel; import bisq.core.trade.protocol.ProcessModelServiceProvider; import bisq.core.trade.txproof.AssetTxProofResult; +import bisq.core.util.VolumeUtil; import bisq.network.p2p.NodeAddress; @@ -623,13 +623,11 @@ public void initialize(ProcessModelServiceProvider serviceProvider) { arbitratorPubKeyRing = arbitrator.getPubKeyRing(); }); - serviceProvider.getMediatorManager().getDisputeAgentByNodeAddress(mediatorNodeAddress).ifPresent(mediator -> { - mediatorPubKeyRing = mediator.getPubKeyRing(); - }); + serviceProvider.getMediatorManager().getDisputeAgentByNodeAddress(mediatorNodeAddress) + .ifPresent(mediator -> mediatorPubKeyRing = mediator.getPubKeyRing()); - serviceProvider.getRefundAgentManager().getDisputeAgentByNodeAddress(refundAgentNodeAddress).ifPresent(refundAgent -> { - refundAgentPubKeyRing = refundAgent.getPubKeyRing(); - }); + serviceProvider.getRefundAgentManager().getDisputeAgentByNodeAddress(refundAgentNodeAddress) + .ifPresent(refundAgent -> refundAgentPubKeyRing = refundAgent.getPubKeyRing()); isInitialized = true; } @@ -831,9 +829,9 @@ public Volume getTradeVolume() { Volume volumeByAmount = getTradePrice().getVolumeByAmount(getTradeAmount()); if (offer != null) { if (offer.getPaymentMethod().getId().equals(PaymentMethod.HAL_CASH_ID)) - volumeByAmount = OfferUtil.getAdjustedVolumeForHalCash(volumeByAmount); + volumeByAmount = VolumeUtil.getAdjustedVolumeForHalCash(volumeByAmount); else if (CurrencyUtil.isFiatCurrency(offer.getCurrencyCode())) - volumeByAmount = OfferUtil.getRoundedFiatVolume(volumeByAmount); + volumeByAmount = VolumeUtil.getRoundedFiatVolume(volumeByAmount); } return volumeByAmount; } else { @@ -864,15 +862,15 @@ private long getTradeStartTime() { if (depositTx.getConfidence().getDepthInBlocks() > 0) { final long tradeTime = getTakeOfferDate().getTime(); // Use tx.getIncludedInBestChainAt() when available, otherwise use tx.getUpdateTime() - long blockTime = depositTx.getIncludedInBestChainAt() != null ? depositTx.getIncludedInBestChainAt().getTime() : depositTx.getUpdateTime().getTime(); + long blockTime = depositTx.getIncludedInBestChainAt() != null + ? depositTx.getIncludedInBestChainAt().getTime() + : depositTx.getUpdateTime().getTime(); // If block date is in future (Date in Bitcoin blocks can be off by +/- 2 hours) we use our current date. // If block date is earlier than our trade date we use our trade date. if (blockTime > now) startTime = now; - else if (blockTime < tradeTime) - startTime = tradeTime; else - startTime = blockTime; + startTime = Math.max(blockTime, tradeTime); log.debug("We set the start for the trade period to {}. Trade started at: {}. Block got mined at: {}", new Date(startTime), new Date(tradeTime), new Date(blockTime)); @@ -929,13 +927,9 @@ public boolean isFundsLockedIn() { // In refund agent case the funds are spent anyway with the time locked payout. We do not consider that as // locked in funds. - if (disputeState == DisputeState.REFUND_REQUESTED || - disputeState == DisputeState.REFUND_REQUEST_STARTED_BY_PEER || - disputeState == DisputeState.REFUND_REQUEST_CLOSED) { - return false; - } - - return true; + return disputeState != DisputeState.REFUND_REQUESTED && + disputeState != DisputeState.REFUND_REQUEST_STARTED_BY_PEER && + disputeState != DisputeState.REFUND_REQUEST_CLOSED; } public boolean isDepositConfirmed() { diff --git a/core/src/main/java/bisq/core/trade/statistics/TradeStatistics2.java b/core/src/main/java/bisq/core/trade/statistics/TradeStatistics2.java index 0b7830a34cb..864b08f9677 100644 --- a/core/src/main/java/bisq/core/trade/statistics/TradeStatistics2.java +++ b/core/src/main/java/bisq/core/trade/statistics/TradeStatistics2.java @@ -23,8 +23,8 @@ import bisq.core.monetary.Volume; import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; -import bisq.core.offer.OfferUtil; import bisq.core.trade.Trade; +import bisq.core.util.VolumeUtil; import bisq.network.p2p.NodeAddress; import bisq.network.p2p.storage.payload.CapabilityRequiringPayload; @@ -310,7 +310,7 @@ public Volume getTradeVolume() { return new Volume(new AltcoinExchangeRate((Altcoin) getTradePrice().getMonetary()).coinToAltcoin(getTradeAmount())); } else { Volume volume = new Volume(new ExchangeRate((Fiat) getTradePrice().getMonetary()).coinToFiat(getTradeAmount())); - return OfferUtil.getRoundedFiatVolume(volume); + return VolumeUtil.getRoundedFiatVolume(volume); } } diff --git a/core/src/main/java/bisq/core/trade/statistics/TradeStatistics3.java b/core/src/main/java/bisq/core/trade/statistics/TradeStatistics3.java index a3f3e996a07..8f4cf09d154 100644 --- a/core/src/main/java/bisq/core/trade/statistics/TradeStatistics3.java +++ b/core/src/main/java/bisq/core/trade/statistics/TradeStatistics3.java @@ -23,8 +23,8 @@ import bisq.core.monetary.Volume; import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; -import bisq.core.offer.OfferUtil; import bisq.core.trade.Trade; +import bisq.core.util.VolumeUtil; import bisq.network.p2p.NodeAddress; import bisq.network.p2p.storage.payload.CapabilityRequiringPayload; @@ -355,7 +355,7 @@ public Volume getTradeVolume() { return new Volume(new AltcoinExchangeRate((Altcoin) getTradePrice().getMonetary()).coinToAltcoin(getTradeAmount())); } else { Volume volume = new Volume(new ExchangeRate((Fiat) getTradePrice().getMonetary()).coinToFiat(getTradeAmount())); - return OfferUtil.getRoundedFiatVolume(volume); + return VolumeUtil.getRoundedFiatVolume(volume); } } diff --git a/core/src/main/java/bisq/core/util/VolumeUtil.java b/core/src/main/java/bisq/core/util/VolumeUtil.java new file mode 100644 index 00000000000..71712bd3657 --- /dev/null +++ b/core/src/main/java/bisq/core/util/VolumeUtil.java @@ -0,0 +1,50 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.util; + +import bisq.core.monetary.Volume; + +public class VolumeUtil { + + public static Volume getRoundedFiatVolume(Volume volumeByAmount) { + // We want to get rounded to 1 unit of the fiat currency, e.g. 1 EUR. + return getAdjustedFiatVolume(volumeByAmount, 1); + } + + public static Volume getAdjustedVolumeForHalCash(Volume volumeByAmount) { + // EUR has precision 4 and we want multiple of 10 so we divide by 100000 then + // round and multiply with 10 + return getAdjustedFiatVolume(volumeByAmount, 10); + } + + /** + * + * @param volumeByAmount The volume generated from an amount + * @param factor The factor used for rounding. E.g. 1 means rounded to + * units of 1 EUR, 10 means rounded to 10 EUR. + * @return The adjusted Fiat volume + */ + public static Volume getAdjustedFiatVolume(Volume volumeByAmount, int factor) { + // Fiat currencies use precision 4 and we want multiple of factor so we divide by 10000 * factor then + // round and multiply with factor + long roundedVolume = Math.round((double) volumeByAmount.getValue() / (10000d * factor)) * factor; + // Smallest allowed volume is factor (e.g. 10 EUR or 1 EUR,...) + roundedVolume = Math.max(factor, roundedVolume); + return Volume.parse(String.valueOf(roundedVolume), volumeByAmount.getCurrencyCode()); + } +} diff --git a/core/src/main/java/bisq/core/util/coin/CoinUtil.java b/core/src/main/java/bisq/core/util/coin/CoinUtil.java index d6c90d9e364..17e0195aad1 100644 --- a/core/src/main/java/bisq/core/util/coin/CoinUtil.java +++ b/core/src/main/java/bisq/core/util/coin/CoinUtil.java @@ -17,10 +17,22 @@ package bisq.core.util.coin; +import bisq.core.btc.wallet.Restrictions; +import bisq.core.monetary.Price; +import bisq.core.monetary.Volume; +import bisq.core.provider.fee.FeeService; + import bisq.common.util.MathUtils; import org.bitcoinj.core.Coin; +import com.google.common.annotations.VisibleForTesting; + +import javax.annotation.Nullable; + +import static bisq.core.util.VolumeUtil.getAdjustedFiatVolume; +import static com.google.common.base.Preconditions.checkArgument; + public class CoinUtil { // Get the fee per amount @@ -75,4 +87,101 @@ public static Coin getPercentOfAmountAsCoin(double percent, Coin amount) { double amountAsDouble = amount != null ? (double) amount.value : 0; return Coin.valueOf(Math.round(percent * amountAsDouble)); } + + + /** + * Calculates the maker fee for the given amount, marketPrice and marketPriceMargin. + * + * @param isCurrencyForMakerFeeBtc {@code true} to pay fee in BTC, {@code false} to pay fee in BSQ + * @param amount the amount of BTC to trade + * @return the maker fee for the given trade amount, or {@code null} if the amount is {@code null} + */ + @Nullable + public static Coin getMakerFee(boolean isCurrencyForMakerFeeBtc, @Nullable Coin amount) { + if (amount != null) { + Coin feePerBtc = getFeePerBtc(FeeService.getMakerFeePerBtc(isCurrencyForMakerFeeBtc), amount); + return maxCoin(feePerBtc, FeeService.getMinMakerFee(isCurrencyForMakerFeeBtc)); + } else { + return null; + } + } + + /** + * Calculate the possibly adjusted amount for {@code amount}, taking into account the + * {@code price} and {@code maxTradeLimit} and {@code factor}. + * + * @param amount Bitcoin amount which is a candidate for getting rounded. + * @param price Price used in relation to that amount. + * @param maxTradeLimit The max. trade limit of the users account, in satoshis. + * @return The adjusted amount + */ + public static Coin getRoundedFiatAmount(Coin amount, Price price, long maxTradeLimit) { + return getAdjustedAmount(amount, price, maxTradeLimit, 1); + } + + public static Coin getAdjustedAmountForHalCash(Coin amount, Price price, long maxTradeLimit) { + return getAdjustedAmount(amount, price, maxTradeLimit, 10); + } + + /** + * Calculate the possibly adjusted amount for {@code amount}, taking into account the + * {@code price} and {@code maxTradeLimit} and {@code factor}. + * + * @param amount Bitcoin amount which is a candidate for getting rounded. + * @param price Price used in relation to that amount. + * @param maxTradeLimit The max. trade limit of the users account, in satoshis. + * @param factor The factor used for rounding. E.g. 1 means rounded to units of + * 1 EUR, 10 means rounded to 10 EUR, etc. + * @return The adjusted amount + */ + @VisibleForTesting + static Coin getAdjustedAmount(Coin amount, Price price, long maxTradeLimit, int factor) { + checkArgument( + amount.getValue() >= 10_000, + "amount needs to be above minimum of 10k satoshis" + ); + checkArgument( + factor > 0, + "factor needs to be positive" + ); + // Amount must result in a volume of min factor units of the fiat currency, e.g. 1 EUR or + // 10 EUR in case of HalCash. + Volume smallestUnitForVolume = Volume.parse(String.valueOf(factor), price.getCurrencyCode()); + if (smallestUnitForVolume.getValue() <= 0) + return Coin.ZERO; + + Coin smallestUnitForAmount = price.getAmountByVolume(smallestUnitForVolume); + long minTradeAmount = Restrictions.getMinTradeAmount().value; + + // We use 10 000 satoshi as min allowed amount + checkArgument( + minTradeAmount >= 10_000, + "MinTradeAmount must be at least 10k satoshis" + ); + smallestUnitForAmount = Coin.valueOf(Math.max(minTradeAmount, smallestUnitForAmount.value)); + // We don't allow smaller amount values than smallestUnitForAmount + boolean useSmallestUnitForAmount = amount.compareTo(smallestUnitForAmount) < 0; + + // We get the adjusted volume from our amount + Volume volume = useSmallestUnitForAmount + ? getAdjustedFiatVolume(price.getVolumeByAmount(smallestUnitForAmount), factor) + : getAdjustedFiatVolume(price.getVolumeByAmount(amount), factor); + if (volume.getValue() <= 0) + return Coin.ZERO; + + // From that adjusted volume we calculate back the amount. It might be a bit different as + // the amount used as input before due rounding. + Coin amountByVolume = price.getAmountByVolume(volume); + + // For the amount we allow only 4 decimal places + long adjustedAmount = Math.round((double) amountByVolume.value / 10000d) * 10000; + + // If we are above our trade limit we reduce the amount by the smallestUnitForAmount + while (adjustedAmount > maxTradeLimit) { + adjustedAmount -= smallestUnitForAmount.value; + } + adjustedAmount = Math.max(minTradeAmount, adjustedAmount); + adjustedAmount = Math.min(maxTradeLimit, adjustedAmount); + return Coin.valueOf(adjustedAmount); + } } diff --git a/core/src/test/java/bisq/core/util/CoinCryptoUtilsTest.java b/core/src/test/java/bisq/core/util/CoinCryptoUtilsTest.java deleted file mode 100644 index 2f04b7a75f4..00000000000 --- a/core/src/test/java/bisq/core/util/CoinCryptoUtilsTest.java +++ /dev/null @@ -1,60 +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.core.util; - -import bisq.core.util.coin.CoinUtil; - -import org.bitcoinj.core.Coin; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class CoinCryptoUtilsTest { - private static final Logger log = LoggerFactory.getLogger(CoinCryptoUtilsTest.class); - - @Test - public void testGetFeePerBtc() { - assertEquals(Coin.parseCoin("1"), CoinUtil.getFeePerBtc(Coin.parseCoin("1"), Coin.parseCoin("1"))); - assertEquals(Coin.parseCoin("0.1"), CoinUtil.getFeePerBtc(Coin.parseCoin("0.1"), Coin.parseCoin("1"))); - assertEquals(Coin.parseCoin("0.01"), CoinUtil.getFeePerBtc(Coin.parseCoin("0.1"), Coin.parseCoin("0.1"))); - assertEquals(Coin.parseCoin("0.015"), CoinUtil.getFeePerBtc(Coin.parseCoin("0.3"), Coin.parseCoin("0.05"))); - } - - @Test - public void testMinCoin() { - assertEquals(Coin.parseCoin("1"), CoinUtil.minCoin(Coin.parseCoin("1"), Coin.parseCoin("1"))); - assertEquals(Coin.parseCoin("0.1"), CoinUtil.minCoin(Coin.parseCoin("0.1"), Coin.parseCoin("1"))); - assertEquals(Coin.parseCoin("0.01"), CoinUtil.minCoin(Coin.parseCoin("0.1"), Coin.parseCoin("0.01"))); - assertEquals(Coin.parseCoin("0"), CoinUtil.minCoin(Coin.parseCoin("0"), Coin.parseCoin("0.05"))); - assertEquals(Coin.parseCoin("0"), CoinUtil.minCoin(Coin.parseCoin("0.05"), Coin.parseCoin("0"))); - } - - @Test - public void testMaxCoin() { - assertEquals(Coin.parseCoin("1"), CoinUtil.maxCoin(Coin.parseCoin("1"), Coin.parseCoin("1"))); - assertEquals(Coin.parseCoin("1"), CoinUtil.maxCoin(Coin.parseCoin("0.1"), Coin.parseCoin("1"))); - assertEquals(Coin.parseCoin("0.1"), CoinUtil.maxCoin(Coin.parseCoin("0.1"), Coin.parseCoin("0.01"))); - assertEquals(Coin.parseCoin("0.05"), CoinUtil.maxCoin(Coin.parseCoin("0"), Coin.parseCoin("0.05"))); - assertEquals(Coin.parseCoin("0.05"), CoinUtil.maxCoin(Coin.parseCoin("0.05"), Coin.parseCoin("0"))); - } - -} diff --git a/core/src/test/java/bisq/core/offer/OfferUtilTest.java b/core/src/test/java/bisq/core/util/coin/CoinUtilTest.java similarity index 57% rename from core/src/test/java/bisq/core/offer/OfferUtilTest.java rename to core/src/test/java/bisq/core/util/coin/CoinUtilTest.java index 2c7093d1ccc..d4c2e683ef0 100644 --- a/core/src/test/java/bisq/core/offer/OfferUtilTest.java +++ b/core/src/test/java/bisq/core/util/coin/CoinUtilTest.java @@ -15,62 +15,90 @@ * along with Bisq. If not, see . */ -package bisq.core.offer; +package bisq.core.util.coin; import bisq.core.monetary.Price; import org.bitcoinj.core.Coin; -import org.junit.Assert; import org.junit.Test; -public class OfferUtilTest { +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class CoinUtilTest { + + @Test + public void testGetFeePerBtc() { + assertEquals(Coin.parseCoin("1"), CoinUtil.getFeePerBtc(Coin.parseCoin("1"), Coin.parseCoin("1"))); + assertEquals(Coin.parseCoin("0.1"), CoinUtil.getFeePerBtc(Coin.parseCoin("0.1"), Coin.parseCoin("1"))); + assertEquals(Coin.parseCoin("0.01"), CoinUtil.getFeePerBtc(Coin.parseCoin("0.1"), Coin.parseCoin("0.1"))); + assertEquals(Coin.parseCoin("0.015"), CoinUtil.getFeePerBtc(Coin.parseCoin("0.3"), Coin.parseCoin("0.05"))); + } + + @Test + public void testMinCoin() { + assertEquals(Coin.parseCoin("1"), CoinUtil.minCoin(Coin.parseCoin("1"), Coin.parseCoin("1"))); + assertEquals(Coin.parseCoin("0.1"), CoinUtil.minCoin(Coin.parseCoin("0.1"), Coin.parseCoin("1"))); + assertEquals(Coin.parseCoin("0.01"), CoinUtil.minCoin(Coin.parseCoin("0.1"), Coin.parseCoin("0.01"))); + assertEquals(Coin.parseCoin("0"), CoinUtil.minCoin(Coin.parseCoin("0"), Coin.parseCoin("0.05"))); + assertEquals(Coin.parseCoin("0"), CoinUtil.minCoin(Coin.parseCoin("0.05"), Coin.parseCoin("0"))); + } + + @Test + public void testMaxCoin() { + assertEquals(Coin.parseCoin("1"), CoinUtil.maxCoin(Coin.parseCoin("1"), Coin.parseCoin("1"))); + assertEquals(Coin.parseCoin("1"), CoinUtil.maxCoin(Coin.parseCoin("0.1"), Coin.parseCoin("1"))); + assertEquals(Coin.parseCoin("0.1"), CoinUtil.maxCoin(Coin.parseCoin("0.1"), Coin.parseCoin("0.01"))); + assertEquals(Coin.parseCoin("0.05"), CoinUtil.maxCoin(Coin.parseCoin("0"), Coin.parseCoin("0.05"))); + assertEquals(Coin.parseCoin("0.05"), CoinUtil.maxCoin(Coin.parseCoin("0.05"), Coin.parseCoin("0"))); + } @Test public void testGetAdjustedAmount() { - Coin result = OfferUtil.getAdjustedAmount( + Coin result = CoinUtil.getAdjustedAmount( Coin.valueOf(100_000), Price.valueOf("USD", 1000_0000), 20_000_000, 1); - Assert.assertEquals( + assertEquals( "Minimum trade amount allowed should be adjusted to the smallest trade allowed.", "0.001 BTC", result.toFriendlyString() ); try { - OfferUtil.getAdjustedAmount( + CoinUtil.getAdjustedAmount( Coin.ZERO, Price.valueOf("USD", 1000_0000), 20_000_000, 1); - Assert.fail("Expected IllegalArgumentException to be thrown when amount is too low."); + fail("Expected IllegalArgumentException to be thrown when amount is too low."); } catch (IllegalArgumentException iae) { - Assert.assertEquals( + assertEquals( "Unexpected exception message.", "amount needs to be above minimum of 10k satoshis", iae.getMessage() ); } - result = OfferUtil.getAdjustedAmount( + result = CoinUtil.getAdjustedAmount( Coin.valueOf(1_000_000), Price.valueOf("USD", 1000_0000), 20_000_000, 1); - Assert.assertEquals( + assertEquals( "Minimum allowed trade amount should not be adjusted.", "0.01 BTC", result.toFriendlyString() ); - result = OfferUtil.getAdjustedAmount( + result = CoinUtil.getAdjustedAmount( Coin.valueOf(100_000), Price.valueOf("USD", 1000_0000), 1_000_000, 1); - Assert.assertEquals( + assertEquals( "Minimum trade amount allowed should respect maxTradeLimit and factor, if possible.", "0.001 BTC", result.toFriendlyString() @@ -81,12 +109,12 @@ public void testGetAdjustedAmount() { // max trade limit is 5k sat = 0.00005 BTC. But the returned amount 0.00005 BTC, or // 0.05 USD worth, which is below the factor of 1 USD, but does respect the maxTradeLimit. // Basically the given constraints (maxTradeLimit vs factor) are impossible to both fulfill.. - result = OfferUtil.getAdjustedAmount( + result = CoinUtil.getAdjustedAmount( Coin.valueOf(100_000), Price.valueOf("USD", 1000_0000), 5_000, 1); - Assert.assertEquals( + assertEquals( "Minimum trade amount allowed with low maxTradeLimit should still respect that limit, even if result does not respect the factor specified.", "0.00005 BTC", result.toFriendlyString() diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java index 2cee16b601d..d7785935563 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java @@ -36,11 +36,12 @@ import javax.inject.Inject; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import static bisq.core.api.model.OfferInfo.toOfferInfo; + @Slf4j class GrpcOffersService extends OffersGrpc.OffersImplBase { @@ -72,7 +73,7 @@ public void getOffer(GetOfferRequest req, public void getOffers(GetOffersRequest req, StreamObserver responseObserver) { List result = coreApi.getOffers(req.getDirection(), req.getCurrencyCode()) - .stream().map(this::toOfferInfo) + .stream().map(OfferInfo::toOfferInfo) .collect(Collectors.toList()); var reply = GetOffersReply.newBuilder() .addAllOffers(result.stream() @@ -113,28 +114,4 @@ public void createOffer(CreateOfferRequest req, throw ex; } } - - // The client cannot see bisq.core.Offer or its fromProto method. - // We use the lighter weight OfferInfo proto wrapper instead, containing just - // enough fields to view and create offers. - private OfferInfo toOfferInfo(Offer offer) { - return new OfferInfo.OfferInfoBuilder() - .withId(offer.getId()) - .withDirection(offer.getDirection().name()) - .withPrice(Objects.requireNonNull(offer.getPrice()).getValue()) - .withUseMarketBasedPrice(offer.isUseMarketBasedPrice()) - .withMarketPriceMargin(offer.getMarketPriceMargin()) - .withAmount(offer.getAmount().value) - .withMinAmount(offer.getMinAmount().value) - .withVolume(Objects.requireNonNull(offer.getVolume()).getValue()) - .withMinVolume(Objects.requireNonNull(offer.getMinVolume()).getValue()) - .withBuyerSecurityDeposit(offer.getBuyerSecurityDeposit().value) - .withPaymentAccountId(offer.getMakerPaymentAccountId()) - .withPaymentMethodId(offer.getPaymentMethod().getId()) - .withPaymentMethodShortName(offer.getPaymentMethod().getShortName()) - .withBaseCurrencyCode(offer.getOfferPayload().getBaseCurrencyCode()) - .withCounterCurrencyCode(offer.getOfferPayload().getCounterCurrencyCode()) - .withDate(offer.getDate().getTime()) - .build(); - } } diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java index 4937e09092e..bb9dbebd273 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java @@ -45,6 +45,7 @@ public GrpcServer(Config config, GrpcPriceService priceService, GrpcVersionService versionService, GrpcGetTradeStatisticsService tradeStatisticsService, + GrpcTradesService tradesService, GrpcWalletsService walletsService) { this.server = ServerBuilder.forPort(config.apiPort) .addService(disputeAgentsService) @@ -52,6 +53,7 @@ public GrpcServer(Config config, .addService(paymentAccountsService) .addService(priceService) .addService(tradeStatisticsService) + .addService(tradesService) .addService(versionService) .addService(walletsService) .intercept(passwordAuthInterceptor) diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java new file mode 100644 index 00000000000..0ffbd71f044 --- /dev/null +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java @@ -0,0 +1,121 @@ +/* + * 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.daemon.grpc; + +import bisq.core.api.CoreApi; +import bisq.core.api.model.TradeInfo; +import bisq.core.trade.Trade; + +import bisq.proto.grpc.ConfirmPaymentReceivedReply; +import bisq.proto.grpc.ConfirmPaymentReceivedRequest; +import bisq.proto.grpc.ConfirmPaymentStartedReply; +import bisq.proto.grpc.ConfirmPaymentStartedRequest; +import bisq.proto.grpc.GetTradeReply; +import bisq.proto.grpc.GetTradeRequest; +import bisq.proto.grpc.TakeOfferReply; +import bisq.proto.grpc.TakeOfferRequest; +import bisq.proto.grpc.TradesGrpc; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +import javax.inject.Inject; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.core.api.model.TradeInfo.toTradeInfo; + +@Slf4j +class GrpcTradesService extends TradesGrpc.TradesImplBase { + + private final CoreApi coreApi; + + @Inject + public GrpcTradesService(CoreApi coreApi) { + this.coreApi = coreApi; + } + + @Override + public void getTrade(GetTradeRequest req, + StreamObserver responseObserver) { + try { + Trade trade = coreApi.getTrade(req.getTradeId()); + var reply = GetTradeReply.newBuilder() + .setTrade(toTradeInfo(trade).toProtoMessage()) + .build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (IllegalStateException | IllegalArgumentException cause) { + var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); + responseObserver.onError(ex); + throw ex; + } + } + + @Override + public void takeOffer(TakeOfferRequest req, + StreamObserver responseObserver) { + try { + coreApi.takeOffer(req.getOfferId(), + req.getPaymentAccountId(), + trade -> { + TradeInfo tradeInfo = toTradeInfo(trade); + var reply = TakeOfferReply.newBuilder() + .setTrade(tradeInfo.toProtoMessage()) + .build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + }); + } catch (IllegalStateException | IllegalArgumentException cause) { + var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); + responseObserver.onError(ex); + throw ex; + } + } + + @Override + public void confirmPaymentStarted(ConfirmPaymentStartedRequest req, + StreamObserver responseObserver) { + try { + coreApi.confirmPaymentStarted(req.getTradeId()); + var reply = ConfirmPaymentStartedReply.newBuilder().build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (IllegalStateException | IllegalArgumentException cause) { + var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); + responseObserver.onError(ex); + throw ex; + } + } + + @Override + public void confirmPaymentReceived(ConfirmPaymentReceivedRequest req, + StreamObserver responseObserver) { + try { + coreApi.confirmPaymentReceived(req.getTradeId()); + var reply = ConfirmPaymentReceivedReply.newBuilder().build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (IllegalStateException | IllegalArgumentException cause) { + var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); + responseObserver.onError(ex); + throw ex; + } + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/offer/MakerFeeProvider.java b/desktop/src/main/java/bisq/desktop/main/offer/MakerFeeProvider.java deleted file mode 100644 index 2243a9e9b06..00000000000 --- a/desktop/src/main/java/bisq/desktop/main/offer/MakerFeeProvider.java +++ /dev/null @@ -1,13 +0,0 @@ -package bisq.desktop.main.offer; - -import bisq.core.btc.wallet.BsqWalletService; -import bisq.core.offer.OfferUtil; -import bisq.core.user.Preferences; - -import org.bitcoinj.core.Coin; - -public class MakerFeeProvider { - public Coin getMakerFee(BsqWalletService bsqWalletService, Preferences preferences, Coin amount) { - return OfferUtil.getMakerFee(bsqWalletService, preferences, amount); - } -} diff --git a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java index d035956cc73..966e54508b8 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java @@ -38,7 +38,6 @@ import bisq.core.offer.OfferPayload; import bisq.core.offer.OfferUtil; import bisq.core.offer.OpenOfferManager; -import bisq.core.payment.HalCashAccount; import bisq.core.payment.PaymentAccount; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; @@ -48,6 +47,7 @@ import bisq.core.user.Preferences; import bisq.core.user.User; import bisq.core.util.FormattingUtils; +import bisq.core.util.VolumeUtil; import bisq.core.util.coin.CoinFormatter; import bisq.core.util.coin.CoinUtil; @@ -85,6 +85,7 @@ import java.util.Date; import java.util.HashSet; import java.util.Optional; +import java.util.function.Predicate; import java.util.stream.Collectors; import javax.annotation.Nullable; @@ -103,7 +104,6 @@ public abstract class MutableOfferDataModel extends OfferDataModel implements Bs private final AccountAgeWitnessService accountAgeWitnessService; private final FeeService feeService; private final CoinFormatter btcFormatter; - private final MakerFeeProvider makerFeeProvider; private final Navigation navigation; private final String offerId; private final BalanceListener btcBalanceListener; @@ -133,6 +133,9 @@ public abstract class MutableOfferDataModel extends OfferDataModel implements Bs protected boolean allowAmountUpdate = true; private final TradeStatisticsManager tradeStatisticsManager; + private final Predicate> isNonZeroAmount = (c) -> c.get() != null && !c.get().isZero(); + private final Predicate> isNonZeroPrice = (p) -> p.get() != null && !p.get().isZero(); + private final Predicate> isNonZeroVolume = (v) -> v.get() != null && !v.get().isZero(); /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, lifecycle @@ -141,6 +144,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel implements Bs @Inject public MutableOfferDataModel(CreateOfferService createOfferService, OpenOfferManager openOfferManager, + OfferUtil offerUtil, BtcWalletService btcWalletService, BsqWalletService bsqWalletService, Preferences preferences, @@ -150,10 +154,9 @@ public MutableOfferDataModel(CreateOfferService createOfferService, AccountAgeWitnessService accountAgeWitnessService, FeeService feeService, @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, - MakerFeeProvider makerFeeProvider, TradeStatisticsManager tradeStatisticsManager, Navigation navigation) { - super(btcWalletService); + super(btcWalletService, offerUtil); this.createOfferService = createOfferService; this.openOfferManager = openOfferManager; @@ -165,7 +168,6 @@ public MutableOfferDataModel(CreateOfferService createOfferService, this.accountAgeWitnessService = accountAgeWitnessService; this.feeService = feeService; this.btcFormatter = btcFormatter; - this.makerFeeProvider = makerFeeProvider; this.navigation = navigation; this.tradeStatisticsManager = tradeStatisticsManager; @@ -373,16 +375,9 @@ private void setSuggestedSecurityDeposit(PaymentAccount paymentAccount) { } } - private void setTradeCurrencyFromPaymentAccount(PaymentAccount paymentAccount) { - if (!paymentAccount.getTradeCurrencies().contains(tradeCurrency)) { - if (paymentAccount.getSelectedTradeCurrency() != null) - tradeCurrency = paymentAccount.getSelectedTradeCurrency(); - else if (paymentAccount.getSingleTradeCurrency() != null) - tradeCurrency = paymentAccount.getSingleTradeCurrency(); - else if (!paymentAccount.getTradeCurrencies().isEmpty()) - tradeCurrency = paymentAccount.getTradeCurrencies().get(0); - } + if (!paymentAccount.getTradeCurrencies().contains(tradeCurrency)) + tradeCurrency = paymentAccount.getTradeCurrency().orElse(tradeCurrency); checkNotNull(tradeCurrency, "tradeCurrency must not be null"); tradeCurrencyCode.set(tradeCurrency.getCode()); @@ -406,7 +401,8 @@ void onCurrencySelected(TradeCurrency tradeCurrency) { priceFeedService.setCurrencyCode(code); - Optional tradeCurrencyOptional = preferences.getTradeCurrenciesAsObservable().stream().filter(e -> e.getCode().equals(code)).findAny(); + Optional tradeCurrencyOptional = preferences.getTradeCurrenciesAsObservable() + .stream().filter(e -> e.getCode().equals(code)).findAny(); if (!tradeCurrencyOptional.isPresent()) { if (CurrencyUtil.isCryptoCurrency(code)) { CurrencyUtil.getCryptoCurrency(code).ifPresent(preferences::addCryptoCurrency); @@ -512,8 +508,8 @@ long getMaxTradeLimit() { /////////////////////////////////////////////////////////////////////////////////////////// double calculateMarketPriceManual(double marketPrice, double volumeAsDouble, double amountAsDouble) { - double manualPriceAsDouble = volumeAsDouble / amountAsDouble; - double percentage = MathUtils.roundDouble(manualPriceAsDouble / marketPrice, 4); + double manualPriceAsDouble = offerUtil.calculateManualPrice(volumeAsDouble, amountAsDouble); + double percentage = offerUtil.calculateMarketPriceMargin(manualPriceAsDouble, marketPrice); setMarketPriceMargin(percentage); @@ -521,10 +517,7 @@ long getMaxTradeLimit() { } void calculateVolume() { - if (price.get() != null && - amount.get() != null && - !amount.get().isZero() && - !price.get().isZero()) { + if (isNonZeroPrice.test(price) && isNonZeroAmount.test(amount)) { try { Volume volumeByAmount = calculateVolumeForAmount(amount); @@ -540,10 +533,7 @@ void calculateVolume() { } void calculateMinVolume() { - if (price.get() != null && - minAmount.get() != null && - !minAmount.get().isZero() && - !price.get().isZero()) { + if (isNonZeroPrice.test(price) && isNonZeroAmount.test(minAmount)) { try { Volume volumeByAmount = calculateVolumeForAmount(minAmount); @@ -559,25 +549,21 @@ private Volume calculateVolumeForAmount(ObjectProperty minAmount) { Volume volumeByAmount = price.get().getVolumeByAmount(minAmount.get()); // For HalCash we want multiple of 10 EUR - if (isHalCashAccount()) - volumeByAmount = OfferUtil.getAdjustedVolumeForHalCash(volumeByAmount); + if (paymentAccount.isHalCashAccount()) + volumeByAmount = VolumeUtil.getAdjustedVolumeForHalCash(volumeByAmount); else if (CurrencyUtil.isFiatCurrency(tradeCurrencyCode.get())) - volumeByAmount = OfferUtil.getRoundedFiatVolume(volumeByAmount); + volumeByAmount = VolumeUtil.getRoundedFiatVolume(volumeByAmount); return volumeByAmount; } void calculateAmount() { - if (volume.get() != null && - price.get() != null && - !volume.get().isZero() && - !price.get().isZero() && - allowAmountUpdate) { + if (isNonZeroPrice.test(price) && isNonZeroVolume.test(volume) && allowAmountUpdate) { try { Coin value = DisplayUtils.reduceTo4Decimals(price.get().getAmountByVolume(volume.get()), btcFormatter); - if (isHalCashAccount()) - value = OfferUtil.getAdjustedAmountForHalCash(value, price.get(), getMaxTradeLimit()); + if (paymentAccount.isHalCashAccount()) + value = CoinUtil.getAdjustedAmountForHalCash(value, price.get(), getMaxTradeLimit()); else if (CurrencyUtil.isFiatCurrency(tradeCurrencyCode.get())) - value = OfferUtil.getRoundedFiatAmount(value, price.get(), getMaxTradeLimit()); + value = CoinUtil.getRoundedFiatAmount(value, price.get(), getMaxTradeLimit()); calculateVolume(); @@ -608,8 +594,8 @@ Coin getSecurityDeposit() { return isBuyOffer() ? getBuyerSecurityDepositAsCoin() : getSellerSecurityDepositAsCoin(); } - public boolean isBuyOffer() { - return OfferUtil.isBuyOffer(getDirection()); + boolean isBuyOffer() { + return offerUtil.isBuyOffer(getDirection()); } public Coin getTxFee() { @@ -645,7 +631,7 @@ void setBuyerSecurityDeposit(double value) { } protected boolean isUseMarketBasedPriceValue() { - return marketPriceAvailable && useMarketBasedPrice.get() && !isHalCashAccount(); + return marketPriceAvailable && useMarketBasedPrice.get() && !paymentAccount.isHalCashAccount(); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -720,13 +706,7 @@ ReadOnlyObjectProperty totalToPayAsCoinProperty() { } Coin getUsableBsqBalance() { - // we have to keep a minimum amount of BSQ == bitcoin dust limit - // otherwise there would be dust violations for change UTXOs - // essentially means the minimum usable balance of BSQ is 5.46 - Coin usableBsqBalance = bsqWalletService.getAvailableConfirmedBalance().subtract(Restrictions.getMinNonDustOutput()); - if (usableBsqBalance.isNegative()) - usableBsqBalance = Coin.ZERO; - return usableBsqBalance; + return offerUtil.getUsableBsqBalance(); } public void setMarketPriceAvailable(boolean marketPriceAvailable) { @@ -734,23 +714,23 @@ public void setMarketPriceAvailable(boolean marketPriceAvailable) { } public Coin getMakerFee(boolean isCurrencyForMakerFeeBtc) { - return OfferUtil.getMakerFee(isCurrencyForMakerFeeBtc, amount.get()); + return CoinUtil.getMakerFee(isCurrencyForMakerFeeBtc, amount.get()); } public Coin getMakerFee() { - return makerFeeProvider.getMakerFee(bsqWalletService, preferences, amount.get()); + return offerUtil.getMakerFee(amount.get()); } public Coin getMakerFeeInBtc() { - return OfferUtil.getMakerFee(true, amount.get()); + return CoinUtil.getMakerFee(true, amount.get()); } public Coin getMakerFeeInBsq() { - return OfferUtil.getMakerFee(false, amount.get()); + return CoinUtil.getMakerFee(false, amount.get()); } public boolean isCurrencyForMakerFeeBtc() { - return OfferUtil.isCurrencyForMakerFeeBtc(preferences, bsqWalletService, amount.get()); + return offerUtil.isCurrencyForMakerFeeBtc(amount.get()); } boolean isPreferredFeeCurrencyBtc() { @@ -758,11 +738,7 @@ boolean isPreferredFeeCurrencyBtc() { } boolean isBsqForFeeAvailable() { - return OfferUtil.isBsqForMakerFeeAvailable(bsqWalletService, amount.get()); - } - - public boolean isHalCashAccount() { - return paymentAccount instanceof HalCashAccount; + return offerUtil.isBsqForMakerFeeAvailable(amount.get()); } boolean canPlaceOffer() { diff --git a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferView.java b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferView.java index 8924495327b..c0dab4099ce 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferView.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferView.java @@ -874,7 +874,7 @@ protected void updatePriceToggle() { int marketPriceAvailableValue = model.marketPriceAvailableProperty.get(); if (marketPriceAvailableValue > -1) { boolean showPriceToggle = marketPriceAvailableValue == 1 && - !model.getDataModel().isHalCashAccount(); + !model.getDataModel().paymentAccount.isHalCashAccount(); percentagePriceBox.setVisible(showPriceToggle); priceTypeToggleButton.setVisible(showPriceToggle); boolean fixedPriceSelected = !model.getDataModel().getUseMarketBasedPrice().get() || !showPriceToggle; diff --git a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java index 248da70bda1..36718c7ba89 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java @@ -54,8 +54,10 @@ import bisq.core.user.Preferences; import bisq.core.util.FormattingUtils; import bisq.core.util.ParsingUtils; +import bisq.core.util.VolumeUtil; import bisq.core.util.coin.BsqFormatter; import bisq.core.util.coin.CoinFormatter; +import bisq.core.util.coin.CoinUtil; import bisq.core.util.validation.InputValidator; import bisq.common.Timer; @@ -63,7 +65,6 @@ import bisq.common.app.DevEnv; import bisq.common.util.MathUtils; -import org.bitcoinj.core.Address; import org.bitcoinj.core.Coin; import org.bitcoinj.utils.Fiat; @@ -96,7 +97,7 @@ public abstract class MutableOfferViewModel ext private final BsqValidator bsqValidator; protected final SecurityDepositValidator securityDepositValidator; private final PriceFeedService priceFeedService; - private AccountAgeWitnessService accountAgeWitnessService; + private final AccountAgeWitnessService accountAgeWitnessService; private final Navigation navigation; private final Preferences preferences; protected final CoinFormatter btcFormatter; @@ -104,9 +105,9 @@ public abstract class MutableOfferViewModel ext private final FiatVolumeValidator fiatVolumeValidator; private final FiatPriceValidator fiatPriceValidator; private final AltcoinValidator altcoinValidator; + protected final OfferUtil offerUtil; private String amountDescription; - private String directionLabel; private String addressAsString; private final String paymentLabel; private boolean createOfferRequested; @@ -156,9 +157,6 @@ public abstract class MutableOfferViewModel ext final ObjectProperty volumeValidationResult = new SimpleObjectProperty<>(); final ObjectProperty buyerSecurityDepositValidationResult = new SimpleObjectProperty<>(); - // Those are needed for the addressTextField - private final ObjectProperty

address = new SimpleObjectProperty<>(); - private ChangeListener amountStringListener; private ChangeListener minAmountStringListener; private ChangeListener priceStringListener, marketPriceMarginStringListener; @@ -172,7 +170,6 @@ public abstract class MutableOfferViewModel ext private ChangeListener securityDepositAsDoubleListener; private ChangeListener isWalletFundedListener; - //private ChangeListener feeFromFundingTxListener; private ChangeListener errorMessageListener; private Offer offer; private Timer timeoutTimer; @@ -201,7 +198,8 @@ public MutableOfferViewModel(M dataModel, Navigation navigation, Preferences preferences, @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, - BsqFormatter bsqFormatter) { + BsqFormatter bsqFormatter, + OfferUtil offerUtil) { super(dataModel); this.fiatVolumeValidator = fiatVolumeValidator; @@ -216,12 +214,12 @@ public MutableOfferViewModel(M dataModel, this.preferences = preferences; this.btcFormatter = btcFormatter; this.bsqFormatter = bsqFormatter; + this.offerUtil = offerUtil; paymentLabel = Res.get("createOffer.fundsBox.paymentLabel", dataModel.shortOfferId); if (dataModel.getAddressEntry() != null) { addressAsString = dataModel.getAddressEntry().getAddressString(); - address.set(dataModel.getAddressEntry().getAddress()); } createListeners(); } @@ -498,8 +496,9 @@ private void applyMakerFee() { tradeFee.set(getFormatterForMakerFee().formatCoin(makerFeeAsCoin)); Coin makerFeeInBtc = dataModel.getMakerFeeInBtc(); - Optional optionalBtcFeeInFiat = OfferUtil.getFeeInUserFiatCurrency(makerFeeInBtc, - true, preferences, priceFeedService, bsqFormatter); + Optional optionalBtcFeeInFiat = offerUtil.getFeeInUserFiatCurrency(makerFeeInBtc, + true, + bsqFormatter); String btcFeeWithFiatAmount = DisplayUtils.getFeeWithFiatAmount(makerFeeInBtc, optionalBtcFeeInFiat, btcFormatter); if (DevEnv.isDaoActivated()) { tradeFeeInBtcWithFiat.set(btcFeeWithFiatAmount); @@ -508,9 +507,12 @@ private void applyMakerFee() { } Coin makerFeeInBsq = dataModel.getMakerFeeInBsq(); - Optional optionalBsqFeeInFiat = OfferUtil.getFeeInUserFiatCurrency(makerFeeInBsq, - false, preferences, priceFeedService, bsqFormatter); - String bsqFeeWithFiatAmount = DisplayUtils.getFeeWithFiatAmount(makerFeeInBsq, optionalBsqFeeInFiat, bsqFormatter); + Optional optionalBsqFeeInFiat = offerUtil.getFeeInUserFiatCurrency(makerFeeInBsq, + false, + bsqFormatter); + String bsqFeeWithFiatAmount = DisplayUtils.getFeeWithFiatAmount(makerFeeInBsq, + optionalBsqFeeInFiat, + bsqFormatter); if (DevEnv.isDaoActivated()) { tradeFeeInBsqWithFiat.set(bsqFeeWithFiatAmount); } else { @@ -604,7 +606,6 @@ boolean initWithData(OfferPayload.Direction direction, TradeCurrency tradeCurren btcValidator.setMinValue(Restrictions.getMinTradeAmount()); final boolean isBuy = dataModel.getDirection() == OfferPayload.Direction.BUY; - directionLabel = isBuy ? Res.get("shared.buyBitcoin") : Res.get("shared.sellBitcoin"); amountDescription = Res.get("createOffer.amountPriceBox.amountDescription", isBuy ? Res.get("shared.buy") : Res.get("shared.sell")); @@ -833,9 +834,7 @@ public void onFocusOutPriceAsPercentageTextField(boolean oldValue, boolean newVa } // We want to trigger a recalculation of the volume - UserThread.execute(() -> { - onFocusOutVolumeTextField(true, false); - }); + UserThread.execute(() -> onFocusOutVolumeTextField(true, false)); } void onFocusOutVolumeTextField(boolean oldValue, boolean newValue) { @@ -849,10 +848,10 @@ void onFocusOutVolumeTextField(boolean oldValue, boolean newValue) { Volume volume = dataModel.getVolume().get(); if (volume != null) { // For HalCash we want multiple of 10 EUR - if (dataModel.isHalCashAccount()) - volume = OfferUtil.getAdjustedVolumeForHalCash(volume); + if (dataModel.paymentAccount.isHalCashAccount()) + volume = VolumeUtil.getAdjustedVolumeForHalCash(volume); else if (CurrencyUtil.isFiatCurrency(tradeCurrencyCode.get())) - volume = OfferUtil.getRoundedFiatVolume(volume); + volume = VolumeUtil.getRoundedFiatVolume(volume); this.volume.set(DisplayUtils.formatVolume(volume)); } @@ -1045,10 +1044,6 @@ public String getAmountDescription() { return amountDescription; } - public String getDirectionLabel() { - return directionLabel; - } - public String getAddressAsString() { return addressAsString; } @@ -1057,10 +1052,6 @@ public String getPaymentLabel() { return paymentLabel; } - public String formatCoin(Coin coin) { - return btcFormatter.formatCoin(coin); - } - public Offer createAndGetOffer() { offer = dataModel.createAndGetOffer(); return offer; @@ -1086,10 +1077,10 @@ private void setAmountToModel() { long maxTradeLimit = dataModel.getMaxTradeLimit(); Price price = dataModel.getPrice().get(); if (price != null) { - if (dataModel.isHalCashAccount()) - amount = OfferUtil.getAdjustedAmountForHalCash(amount, price, maxTradeLimit); + if (dataModel.paymentAccount.isHalCashAccount()) + amount = CoinUtil.getAdjustedAmountForHalCash(amount, price, maxTradeLimit); else if (CurrencyUtil.isFiatCurrency(tradeCurrencyCode.get())) - amount = OfferUtil.getRoundedFiatAmount(amount, price, maxTradeLimit); + amount = CoinUtil.getRoundedFiatAmount(amount, price, maxTradeLimit); } dataModel.setAmount(amount); if (syncMinAmountWithAmount || @@ -1110,10 +1101,10 @@ private void setMinAmountToModel() { Price price = dataModel.getPrice().get(); long maxTradeLimit = dataModel.getMaxTradeLimit(); if (price != null) { - if (dataModel.isHalCashAccount()) - minAmount = OfferUtil.getAdjustedAmountForHalCash(minAmount, price, maxTradeLimit); + if (dataModel.paymentAccount.isHalCashAccount()) + minAmount = CoinUtil.getAdjustedAmountForHalCash(minAmount, price, maxTradeLimit); else if (CurrencyUtil.isFiatCurrency(tradeCurrencyCode.get())) - minAmount = OfferUtil.getRoundedFiatAmount(minAmount, price, maxTradeLimit); + minAmount = CoinUtil.getRoundedFiatAmount(minAmount, price, maxTradeLimit); } dataModel.setMinAmount(minAmount); diff --git a/desktop/src/main/java/bisq/desktop/main/offer/OfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/OfferDataModel.java index c40cbc4f3d2..14e26e1451d 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/OfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/OfferDataModel.java @@ -21,6 +21,7 @@ import bisq.core.btc.model.AddressEntry; import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.offer.OfferUtil; import org.bitcoinj.core.Coin; @@ -31,13 +32,17 @@ import lombok.Getter; +import static bisq.core.util.coin.CoinUtil.minCoin; + /** * Domain for that UI element. - * Note that the create offer domain has a deeper scope in the application domain (TradeManager). - * That model is just responsible for the domain specific parts displayed needed in that UI element. + * Note that the create offer domain has a deeper scope in the application domain + * (TradeManager). That model is just responsible for the domain specific parts displayed + * needed in that UI element. */ public abstract class OfferDataModel extends ActivatableDataModel { protected final BtcWalletService btcWalletService; + protected final OfferUtil offerUtil; @Getter protected final BooleanProperty isBtcWalletFunded = new SimpleBooleanProperty(); @@ -54,8 +59,9 @@ public abstract class OfferDataModel extends ActivatableDataModel { protected AddressEntry addressEntry; protected boolean useSavingsWallet; - public OfferDataModel(BtcWalletService btcWalletService) { + public OfferDataModel(BtcWalletService btcWalletService, OfferUtil offerUtil) { this.btcWalletService = btcWalletService; + this.offerUtil = offerUtil; } protected void updateBalance() { @@ -64,28 +70,15 @@ protected void updateBalance() { Coin savingWalletBalance = btcWalletService.getSavingWalletBalance(); totalAvailableBalance = savingWalletBalance.add(tradeWalletBalance); if (totalToPayAsCoin.get() != null) { - if (totalAvailableBalance.compareTo(totalToPayAsCoin.get()) > 0) - balance.set(totalToPayAsCoin.get()); - else - balance.set(totalAvailableBalance); + balance.set(minCoin(totalToPayAsCoin.get(), totalAvailableBalance)); } } else { balance.set(tradeWalletBalance); } - if (totalToPayAsCoin.get() != null) { - Coin missing = totalToPayAsCoin.get().subtract(balance.get()); - if (missing.isNegative()) - missing = Coin.ZERO; - missingCoin.set(missing); - } - - isBtcWalletFunded.set(isBalanceSufficient(balance.get())); + missingCoin.set(offerUtil.getBalanceShortage(totalToPayAsCoin.get(), balance.get())); + isBtcWalletFunded.set(offerUtil.isBalanceSufficient(totalToPayAsCoin.get(), balance.get())); if (totalToPayAsCoin.get() != null && isBtcWalletFunded.get() && !showWalletFundedNotification.get()) { showWalletFundedNotification.set(true); } } - - private boolean isBalanceSufficient(Coin balance) { - return totalToPayAsCoin.get() != null && balance.compareTo(totalToPayAsCoin.get()) >= 0; - } } diff --git a/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModel.java index c0f5e822f24..0bec3dcfd8a 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModel.java @@ -22,13 +22,13 @@ package bisq.desktop.main.offer.createoffer; import bisq.desktop.Navigation; -import bisq.desktop.main.offer.MakerFeeProvider; import bisq.desktop.main.offer.MutableOfferDataModel; import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.btc.wallet.BsqWalletService; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.offer.CreateOfferService; +import bisq.core.offer.OfferUtil; import bisq.core.offer.OpenOfferManager; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; @@ -54,6 +54,7 @@ class CreateOfferDataModel extends MutableOfferDataModel { @Inject public CreateOfferDataModel(CreateOfferService createOfferService, OpenOfferManager openOfferManager, + OfferUtil offerUtil, BtcWalletService btcWalletService, BsqWalletService bsqWalletService, Preferences preferences, @@ -63,11 +64,11 @@ public CreateOfferDataModel(CreateOfferService createOfferService, AccountAgeWitnessService accountAgeWitnessService, FeeService feeService, @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, - MakerFeeProvider makerFeeProvider, TradeStatisticsManager tradeStatisticsManager, Navigation navigation) { super(createOfferService, openOfferManager, + offerUtil, btcWalletService, bsqWalletService, preferences, @@ -77,7 +78,6 @@ public CreateOfferDataModel(CreateOfferService createOfferService, accountAgeWitnessService, feeService, btcFormatter, - makerFeeProvider, tradeStatisticsManager, navigation); } diff --git a/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModel.java b/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModel.java index 2543a002ef5..62fef3ac7ee 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModel.java @@ -28,6 +28,7 @@ import bisq.desktop.util.validation.SecurityDepositValidator; import bisq.core.account.witness.AccountAgeWitnessService; +import bisq.core.offer.OfferUtil; import bisq.core.provider.price.PriceFeedService; import bisq.core.user.Preferences; import bisq.core.util.FormattingUtils; @@ -53,7 +54,8 @@ public CreateOfferViewModel(CreateOfferDataModel dataModel, Navigation navigation, Preferences preferences, @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, - BsqFormatter bsqFormatter) { + BsqFormatter bsqFormatter, + OfferUtil offerUtil) { super(dataModel, fiatVolumeValidator, fiatPriceValidator, @@ -65,6 +67,8 @@ public CreateOfferViewModel(CreateOfferDataModel dataModel, accountAgeWitnessService, navigation, preferences, - btcFormatter, bsqFormatter); + btcFormatter, + bsqFormatter, + offerUtil); } } diff --git a/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferDataModel.java index 95cff8bc867..399f45bbef3 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferDataModel.java @@ -38,7 +38,6 @@ import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; import bisq.core.offer.OfferUtil; -import bisq.core.payment.HalCashAccount; import bisq.core.payment.PaymentAccount; import bisq.core.payment.PaymentAccountUtil; import bisq.core.payment.payload.PaymentMethod; @@ -48,6 +47,7 @@ import bisq.core.trade.handlers.TradeResultHandler; import bisq.core.user.Preferences; import bisq.core.user.User; +import bisq.core.util.VolumeUtil; import bisq.core.util.coin.CoinUtil; import bisq.network.p2p.P2PService; @@ -123,6 +123,7 @@ class TakeOfferDataModel extends OfferDataModel { @Inject TakeOfferDataModel(TradeManager tradeManager, OfferBook offerBook, + OfferUtil offerUtil, BtcWalletService btcWalletService, BsqWalletService bsqWalletService, User user, FeeService feeService, @@ -134,7 +135,7 @@ class TakeOfferDataModel extends OfferDataModel { Navigation navigation, P2PService p2PService ) { - super(btcWalletService); + super(btcWalletService, offerUtil); this.tradeManager = tradeManager; this.offerBook = offerBook; @@ -463,9 +464,9 @@ void calculateVolume() { !amount.get().isZero()) { Volume volumeByAmount = tradePrice.getVolumeByAmount(amount.get()); if (offer.getPaymentMethod().getId().equals(PaymentMethod.HAL_CASH_ID)) - volumeByAmount = OfferUtil.getAdjustedVolumeForHalCash(volumeByAmount); + volumeByAmount = VolumeUtil.getAdjustedVolumeForHalCash(volumeByAmount); else if (CurrencyUtil.isFiatCurrency(getCurrencyCode())) - volumeByAmount = OfferUtil.getRoundedFiatVolume(volumeByAmount); + volumeByAmount = VolumeUtil.getRoundedFiatVolume(volumeByAmount); volume.set(volumeByAmount); @@ -643,11 +644,11 @@ public Coin getUsableBsqBalance() { } public boolean isHalCashAccount() { - return paymentAccount instanceof HalCashAccount; + return paymentAccount.isHalCashAccount(); } public boolean isCurrencyForTakerFeeBtc() { - return OfferUtil.isCurrencyForTakerFeeBtc(preferences, bsqWalletService, amount.get()); + return offerUtil.isCurrencyForTakerFeeBtc(amount.get()); } public void setPreferredCurrencyForTakerFeeBtc(boolean isCurrencyForTakerFeeBtc) { @@ -659,18 +660,18 @@ public boolean isPreferredFeeCurrencyBtc() { } public Coin getTakerFeeInBtc() { - return OfferUtil.getTakerFee(true, amount.get()); + return offerUtil.getTakerFee(true, amount.get()); } public Coin getTakerFeeInBsq() { - return OfferUtil.getTakerFee(false, amount.get()); + return offerUtil.getTakerFee(false, amount.get()); } boolean isTakerFeeValid() { - return preferences.getPayFeeInBtc() || OfferUtil.isBsqForTakerFeeAvailable(bsqWalletService, amount.get()); + return preferences.getPayFeeInBtc() || offerUtil.isBsqForTakerFeeAvailable(amount.get()); } public boolean isBsqForFeeAvailable() { - return OfferUtil.isBsqForTakerFeeAvailable(bsqWalletService, amount.get()); + return offerUtil.isBsqForTakerFeeAvailable(amount.get()); } } diff --git a/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferViewModel.java b/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferViewModel.java index 5496ca957c5..0bf3855eb24 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/takeoffer/TakeOfferViewModel.java @@ -41,12 +41,11 @@ import bisq.core.payment.PaymentAccount; import bisq.core.payment.payload.PaymentMethod; import bisq.core.provider.fee.FeeService; -import bisq.core.provider.price.PriceFeedService; import bisq.core.trade.Trade; -import bisq.core.user.Preferences; import bisq.core.util.FormattingUtils; import bisq.core.util.coin.BsqFormatter; import bisq.core.util.coin.CoinFormatter; +import bisq.core.util.coin.CoinUtil; import bisq.core.util.validation.InputValidator; import bisq.network.p2p.P2PService; @@ -86,11 +85,10 @@ class TakeOfferViewModel extends ActivatableWithDataModel implements ViewModel { final TakeOfferDataModel dataModel; + private final OfferUtil offerUtil; private final BtcValidator btcValidator; private final P2PService p2PService; - private final Preferences preferences; - private final PriceFeedService priceFeedService; - private AccountAgeWitnessService accountAgeWitnessService; + private final AccountAgeWitnessService accountAgeWitnessService; private final Navigation navigation; private final CoinFormatter btcFormatter; private final BsqFormatter bsqFormatter; @@ -101,7 +99,6 @@ class TakeOfferViewModel extends ActivatableWithDataModel im private Trade trade; private Offer offer; private String price; - private String directionLabel; private String amountDescription; final StringProperty amount = new SimpleStringProperty(); @@ -146,21 +143,18 @@ class TakeOfferViewModel extends ActivatableWithDataModel im @Inject public TakeOfferViewModel(TakeOfferDataModel dataModel, + OfferUtil offerUtil, BtcValidator btcValidator, P2PService p2PService, - Preferences preferences, - PriceFeedService priceFeedService, AccountAgeWitnessService accountAgeWitnessService, Navigation navigation, @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, BsqFormatter bsqFormatter) { super(dataModel); this.dataModel = dataModel; - + this.offerUtil = offerUtil; this.btcValidator = btcValidator; this.p2PService = p2PService; - this.preferences = preferences; - this.priceFeedService = priceFeedService; this.accountAgeWitnessService = accountAgeWitnessService; this.navigation = navigation; this.btcFormatter = btcFormatter; @@ -207,13 +201,9 @@ void initWithData(Offer offer) { dataModel.initWithData(offer); this.offer = offer; - if (offer.isBuyOffer()) { - directionLabel = Res.get("shared.sellBitcoin"); - amountDescription = Res.get("takeOffer.amountPriceBox.buy.amountDescription"); - } else { - directionLabel = Res.get("shared.buyBitcoin"); - amountDescription = Res.get("takeOffer.amountPriceBox.sell.amountDescription"); - } + amountDescription = offer.isBuyOffer() + ? Res.get("takeOffer.amountPriceBox.buy.amountDescription") + : Res.get("takeOffer.amountPriceBox.sell.amountDescription"); amountRange = btcFormatter.formatCoin(offer.getMinAmount()) + " - " + btcFormatter.formatCoin(offer.getAmount()); price = FormattingUtils.formatPrice(dataModel.tradePrice); @@ -296,8 +286,9 @@ private void applyTakerFee() { tradeFee.set(getFormatterForTakerFee().formatCoin(takerFeeAsCoin)); Coin makerFeeInBtc = dataModel.getTakerFeeInBtc(); - Optional optionalBtcFeeInFiat = OfferUtil.getFeeInUserFiatCurrency(makerFeeInBtc, - true, preferences, priceFeedService, bsqFormatter); + Optional optionalBtcFeeInFiat = offerUtil.getFeeInUserFiatCurrency(makerFeeInBtc, + true, + bsqFormatter); String btcFeeWithFiatAmount = DisplayUtils.getFeeWithFiatAmount(makerFeeInBtc, optionalBtcFeeInFiat, btcFormatter); if (DevEnv.isDaoActivated()) { tradeFeeInBtcWithFiat.set(btcFeeWithFiatAmount); @@ -306,8 +297,9 @@ private void applyTakerFee() { } Coin makerFeeInBsq = dataModel.getTakerFeeInBsq(); - Optional optionalBsqFeeInFiat = OfferUtil.getFeeInUserFiatCurrency(makerFeeInBsq, - false, preferences, priceFeedService, bsqFormatter); + Optional optionalBsqFeeInFiat = offerUtil.getFeeInUserFiatCurrency(makerFeeInBsq, + false, + bsqFormatter); String bsqFeeWithFiatAmount = DisplayUtils.getFeeWithFiatAmount(makerFeeInBsq, optionalBsqFeeInFiat, bsqFormatter); if (DevEnv.isDaoActivated()) { tradeFeeInBsqWithFiat.set(bsqFeeWithFiatAmount); @@ -355,7 +347,7 @@ void onFocusOutAmountTextField(boolean oldValue, boolean newValue, String userIn Price tradePrice = dataModel.tradePrice; long maxTradeLimit = dataModel.getMaxTradeLimit(); if (dataModel.getPaymentMethod().getId().equals(PaymentMethod.HAL_CASH_ID)) { - Coin adjustedAmountForHalCash = OfferUtil.getAdjustedAmountForHalCash(dataModel.getAmount().get(), + Coin adjustedAmountForHalCash = CoinUtil.getAdjustedAmountForHalCash(dataModel.getAmount().get(), tradePrice, maxTradeLimit); dataModel.applyAmount(adjustedAmountForHalCash); @@ -364,7 +356,7 @@ void onFocusOutAmountTextField(boolean oldValue, boolean newValue, String userIn if (!isAmountEqualMinAmount(dataModel.getAmount().get()) && (!isAmountEqualMaxAmount(dataModel.getAmount().get()))) { // We only apply the rounding if the amount is variable (minAmount is lower as amount). // Otherwise we could get an amount lower then the minAmount set by rounding - Coin roundedAmount = OfferUtil.getRoundedFiatAmount(dataModel.getAmount().get(), tradePrice, + Coin roundedAmount = CoinUtil.getRoundedFiatAmount(dataModel.getAmount().get(), tradePrice, maxTradeLimit); dataModel.applyAmount(roundedAmount); } @@ -638,12 +630,12 @@ private void setAmountToModel() { Price price = dataModel.tradePrice; if (price != null) { if (dataModel.isHalCashAccount()) { - amount = OfferUtil.getAdjustedAmountForHalCash(amount, price, maxTradeLimit); + amount = CoinUtil.getAdjustedAmountForHalCash(amount, price, maxTradeLimit); } else if (CurrencyUtil.isFiatCurrency(dataModel.getCurrencyCode()) && !isAmountEqualMinAmount(amount) && !isAmountEqualMaxAmount(amount)) { // We only apply the rounding if the amount is variable (minAmount is lower as amount). // Otherwise we could get an amount lower then the minAmount set by rounding - amount = OfferUtil.getRoundedFiatAmount(amount, price, maxTradeLimit); + amount = CoinUtil.getRoundedFiatAmount(amount, price, maxTradeLimit); } } dataModel.applyAmount(amount); @@ -694,10 +686,6 @@ public String getPrice() { return price; } - public String getDirectionLabel() { - return directionLabel; - } - public String getAmountDescription() { return amountDescription; } @@ -757,10 +745,6 @@ public String getTxFeePercentage() { return GUIUtil.getPercentage(txFeeAsCoin, dataModel.getAmount().get()); } - public PaymentMethod getPaymentMethod() { - return dataModel.getPaymentMethod(); - } - ObservableList getPossiblePaymentAccounts() { return dataModel.getPossiblePaymentAccounts(); } @@ -781,14 +765,6 @@ public void resetErrorMessage() { offer.setErrorMessage(null); } - public String getBuyerSecurityDeposit() { - return btcFormatter.formatCoin(dataModel.getBuyerSecurityDeposit()); - } - - public String getSellerSecurityDeposit() { - return btcFormatter.formatCoin(dataModel.getSellerSecurityDeposit()); - } - private CoinFormatter getFormatterForTakerFee() { return dataModel.isCurrencyForTakerFeeBtc() ? btcFormatter : bsqFormatter; } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java index f3ba118a13e..8956e028f5b 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java @@ -19,7 +19,6 @@ import bisq.desktop.Navigation; -import bisq.desktop.main.offer.MakerFeeProvider; import bisq.desktop.main.offer.MutableOfferDataModel; import bisq.core.account.witness.AccountAgeWitnessService; @@ -31,6 +30,7 @@ import bisq.core.offer.CreateOfferService; import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; +import bisq.core.offer.OfferUtil; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; import bisq.core.payment.PaymentAccount; @@ -64,6 +64,7 @@ class EditOfferDataModel extends MutableOfferDataModel { @Inject EditOfferDataModel(CreateOfferService createOfferService, OpenOfferManager openOfferManager, + OfferUtil offerUtil, BtcWalletService btcWalletService, BsqWalletService bsqWalletService, Preferences preferences, @@ -74,11 +75,12 @@ class EditOfferDataModel extends MutableOfferDataModel { FeeService feeService, @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, CorePersistenceProtoResolver corePersistenceProtoResolver, - MakerFeeProvider makerFeeProvider, TradeStatisticsManager tradeStatisticsManager, Navigation navigation) { + super(createOfferService, openOfferManager, + offerUtil, btcWalletService, bsqWalletService, preferences, @@ -88,7 +90,6 @@ class EditOfferDataModel extends MutableOfferDataModel { accountAgeWitnessService, feeService, btcFormatter, - makerFeeProvider, tradeStatisticsManager, navigation); this.corePersistenceProtoResolver = corePersistenceProtoResolver; diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferViewModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferViewModel.java index f85d1978b57..73b3c83781d 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferViewModel.java @@ -27,6 +27,7 @@ import bisq.desktop.util.validation.SecurityDepositValidator; import bisq.core.account.witness.AccountAgeWitnessService; +import bisq.core.offer.OfferUtil; import bisq.core.offer.OpenOffer; import bisq.core.provider.price.PriceFeedService; import bisq.core.user.Preferences; @@ -56,7 +57,8 @@ public EditOfferViewModel(EditOfferDataModel dataModel, Navigation navigation, Preferences preferences, @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, - BsqFormatter bsqFormatter) { + BsqFormatter bsqFormatter, + OfferUtil offerUtil) { super(dataModel, fiatVolumeValidator, fiatPriceValidator, @@ -68,7 +70,9 @@ public EditOfferViewModel(EditOfferDataModel dataModel, accountAgeWitnessService, navigation, preferences, - btcFormatter, bsqFormatter); + btcFormatter, + bsqFormatter, + offerUtil); syncMinAmountWithAmount = false; } diff --git a/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModelTest.java b/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModelTest.java index e9550895894..e8daeb57dc8 100644 --- a/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModelTest.java +++ b/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModelTest.java @@ -1,7 +1,5 @@ package bisq.desktop.main.offer.createoffer; -import bisq.desktop.main.offer.MakerFeeProvider; - import bisq.core.btc.model.AddressEntry; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.locale.CryptoCurrency; @@ -9,7 +7,7 @@ import bisq.core.locale.GlobalSettings; import bisq.core.locale.Res; import bisq.core.offer.CreateOfferService; -import bisq.core.offer.OfferPayload; +import bisq.core.offer.OfferUtil; import bisq.core.payment.ClearXchangeAccount; import bisq.core.payment.PaymentAccount; import bisq.core.payment.RevolutAccount; @@ -29,6 +27,7 @@ import org.junit.Before; import org.junit.Test; +import static bisq.core.offer.OfferPayload.Direction; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -40,7 +39,7 @@ public class CreateOfferDataModelTest { private CreateOfferDataModel model; private User user; private Preferences preferences; - private MakerFeeProvider makerFeeProvider; + private OfferUtil offerUtil; @Before public void setUp() { @@ -54,6 +53,7 @@ public void setUp() { FeeService feeService = mock(FeeService.class); CreateOfferService createOfferService = mock(CreateOfferService.class); preferences = mock(Preferences.class); + offerUtil = mock(OfferUtil.class); user = mock(User.class); var tradeStats = mock(TradeStatisticsManager.class); @@ -63,11 +63,20 @@ public void setUp() { when(createOfferService.getRandomOfferId()).thenReturn(UUID.randomUUID().toString()); when(tradeStats.getObservableTradeStatisticsSet()).thenReturn(FXCollections.observableSet()); - makerFeeProvider = mock(MakerFeeProvider.class); - model = new CreateOfferDataModel(createOfferService, null, btcWalletService, - null, preferences, user, null, - priceFeedService, null, - feeService, null, makerFeeProvider, tradeStats, null); + model = new CreateOfferDataModel(createOfferService, + null, + offerUtil, + btcWalletService, + null, + preferences, + user, + null, + priceFeedService, + null, + feeService, + null, + tradeStats, + null); } @Test @@ -84,9 +93,9 @@ public void testUseTradeCurrencySetInOfferViewWhenInPaymentAccountAvailable() { when(user.getPaymentAccounts()).thenReturn(paymentAccounts); when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount); - when(makerFeeProvider.getMakerFee(any(), any(), any())).thenReturn(Coin.ZERO); + when(offerUtil.getMakerFee(any())).thenReturn(Coin.ZERO); - model.initWithData(OfferPayload.Direction.BUY, new FiatCurrency("USD")); + model.initWithData(Direction.BUY, new FiatCurrency("USD")); assertEquals("USD", model.getTradeCurrencyCode().get()); } @@ -104,10 +113,9 @@ public void testUseTradeAccountThatMatchesTradeCurrencySetInOffer() { when(user.getPaymentAccounts()).thenReturn(paymentAccounts); when(user.findFirstPaymentAccountWithCurrency(new FiatCurrency("USD"))).thenReturn(zelleAccount); when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount); - when(makerFeeProvider.getMakerFee(any(), any(), any())).thenReturn(Coin.ZERO); + when(offerUtil.getMakerFee(any())).thenReturn(Coin.ZERO); - model.initWithData(OfferPayload.Direction.BUY, new FiatCurrency("USD")); + model.initWithData(Direction.BUY, new FiatCurrency("USD")); assertEquals("USD", model.getTradeCurrencyCode().get()); } - } diff --git a/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModelTest.java b/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModelTest.java index 17d8ab802e9..f82c5636e04 100644 --- a/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModelTest.java +++ b/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModelTest.java @@ -17,7 +17,6 @@ package bisq.desktop.main.offer.createoffer; -import bisq.desktop.main.offer.MakerFeeProvider; import bisq.desktop.util.validation.AltcoinValidator; import bisq.desktop.util.validation.BtcValidator; import bisq.desktop.util.validation.FiatPriceValidator; @@ -32,7 +31,7 @@ import bisq.core.locale.GlobalSettings; import bisq.core.locale.Res; import bisq.core.offer.CreateOfferService; -import bisq.core.offer.OfferPayload; +import bisq.core.offer.OfferUtil; import bisq.core.payment.PaymentAccount; import bisq.core.payment.payload.PaymentMethod; import bisq.core.provider.fee.FeeService; @@ -61,6 +60,7 @@ import org.junit.Before; import org.junit.Test; +import static bisq.core.offer.OfferPayload.Direction; import static bisq.desktop.maker.PreferenceMakers.empty; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -73,7 +73,8 @@ public class CreateOfferViewModelTest { private CreateOfferViewModel model; - private final CoinFormatter coinFormatter = new ImmutableCoinFormatter(Config.baseCurrencyNetworkParameters().getMonetaryFormat()); + private final CoinFormatter coinFormatter = new ImmutableCoinFormatter( + Config.baseCurrencyNetworkParameters().getMonetaryFormat()); @Before public void setUp() { @@ -97,12 +98,17 @@ public void setUp() { SecurityDepositValidator securityDepositValidator = mock(SecurityDepositValidator.class); AccountAgeWitnessService accountAgeWitnessService = mock(AccountAgeWitnessService.class); CreateOfferService createOfferService = mock(CreateOfferService.class); + OfferUtil offerUtil = mock(OfferUtil.class); var tradeStats = mock(TradeStatisticsManager.class); when(btcWalletService.getOrCreateAddressEntry(anyString(), any())).thenReturn(addressEntry); when(btcWalletService.getBalanceForAddress(any())).thenReturn(Coin.valueOf(1000L)); when(priceFeedService.updateCounterProperty()).thenReturn(new SimpleIntegerProperty()); - when(priceFeedService.getMarketPrice(anyString())).thenReturn(new MarketPrice("USD", 12684.0450, Instant.now().getEpochSecond(), true)); + when(priceFeedService.getMarketPrice(anyString())).thenReturn( + new MarketPrice("USD", + 12684.0450, + Instant.now().getEpochSecond(), + true)); when(feeService.getTxFee(anyInt())).thenReturn(Coin.valueOf(1000L)); when(user.findFirstPaymentAccountWithCurrency(any())).thenReturn(paymentAccount); when(paymentAccount.getPaymentMethod()).thenReturn(mock(PaymentMethod.class)); @@ -115,16 +121,37 @@ public void setUp() { when(createOfferService.getRandomOfferId()).thenReturn(UUID.randomUUID().toString()); when(tradeStats.getObservableTradeStatisticsSet()).thenReturn(FXCollections.observableSet()); - CreateOfferDataModel dataModel = new CreateOfferDataModel(createOfferService, null, btcWalletService, - bsqWalletService, empty, user, null, priceFeedService, - accountAgeWitnessService, feeService, - coinFormatter, mock(MakerFeeProvider.class), tradeStats, null); - dataModel.initWithData(OfferPayload.Direction.BUY, new CryptoCurrency("BTC", "bitcoin")); + CreateOfferDataModel dataModel = new CreateOfferDataModel(createOfferService, + null, + offerUtil, + btcWalletService, + bsqWalletService, + empty, + user, + null, + priceFeedService, + accountAgeWitnessService, + feeService, + coinFormatter, + tradeStats, + null); + dataModel.initWithData(Direction.BUY, new CryptoCurrency("BTC", "bitcoin")); dataModel.activate(); - model = new CreateOfferViewModel(dataModel, null, fiatPriceValidator, altcoinValidator, - btcValidator, null, securityDepositValidator, priceFeedService, null, null, - preferences, coinFormatter, bsqFormatter); + model = new CreateOfferViewModel(dataModel, + null, + fiatPriceValidator, + altcoinValidator, + btcValidator, + null, + securityDepositValidator, + priceFeedService, + null, + null, + preferences, + coinFormatter, + bsqFormatter, + offerUtil); model.activate(); } diff --git a/desktop/src/test/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModelTest.java b/desktop/src/test/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModelTest.java index a820789916d..d0b4b01cac6 100644 --- a/desktop/src/test/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModelTest.java +++ b/desktop/src/test/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModelTest.java @@ -1,6 +1,5 @@ package bisq.desktop.main.portfolio.editoffer; -import bisq.desktop.main.offer.MakerFeeProvider; import bisq.desktop.util.validation.SecurityDepositValidator; import bisq.core.account.witness.AccountAgeWitnessService; @@ -13,6 +12,7 @@ import bisq.core.locale.Res; import bisq.core.offer.CreateOfferService; import bisq.core.offer.OfferPayload; +import bisq.core.offer.OfferUtil; import bisq.core.offer.OpenOffer; import bisq.core.payment.CryptoCurrencyAccount; import bisq.core.payment.PaymentAccount; @@ -77,11 +77,16 @@ public void setUp() { SecurityDepositValidator securityDepositValidator = mock(SecurityDepositValidator.class); AccountAgeWitnessService accountAgeWitnessService = mock(AccountAgeWitnessService.class); CreateOfferService createOfferService = mock(CreateOfferService.class); + OfferUtil offerUtil = mock(OfferUtil.class); when(btcWalletService.getOrCreateAddressEntry(anyString(), any())).thenReturn(addressEntry); when(btcWalletService.getBalanceForAddress(any())).thenReturn(Coin.valueOf(1000L)); when(priceFeedService.updateCounterProperty()).thenReturn(new SimpleIntegerProperty()); - when(priceFeedService.getMarketPrice(anyString())).thenReturn(new MarketPrice("USD", 12684.0450, Instant.now().getEpochSecond(), true)); + when(priceFeedService.getMarketPrice(anyString())).thenReturn( + new MarketPrice("USD", + 12684.0450, + Instant.now().getEpochSecond(), + true)); when(feeService.getTxFee(anyInt())).thenReturn(Coin.valueOf(1000L)); when(user.findFirstPaymentAccountWithCurrency(any())).thenReturn(paymentAccount); when(user.getPaymentAccountsAsObservable()).thenReturn(FXCollections.observableSet()); @@ -92,11 +97,21 @@ public void setUp() { when(bsqWalletService.getAvailableConfirmedBalance()).thenReturn(Coin.ZERO); when(createOfferService.getRandomOfferId()).thenReturn(UUID.randomUUID().toString()); - model = new EditOfferDataModel(createOfferService, null, - btcWalletService, bsqWalletService, empty, user, - null, priceFeedService, - accountAgeWitnessService, feeService, null, null, - mock(MakerFeeProvider.class), mock(TradeStatisticsManager.class), null); + model = new EditOfferDataModel(createOfferService, + null, + offerUtil, + btcWalletService, + bsqWalletService, + empty, + user, + null, + priceFeedService, + accountAgeWitnessService, + feeService, + null, + null, + mock(TradeStatisticsManager.class), + null); } @Test diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index 1ed87f20853..38ddd4a9dbb 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -96,12 +96,13 @@ message OfferInfo { uint64 volume = 8; uint64 minVolume = 9; uint64 buyerSecurityDeposit = 10; - string paymentAccountId = 11; // only used when creating offer + string paymentAccountId = 11; string paymentMethodId = 12; string paymentMethodShortName = 13; string baseCurrencyCode = 14; string counterCurrencyCode = 15; uint64 date = 16; + string state = 17; } /////////////////////////////////////////////////////////////////////////////////////////// @@ -166,6 +167,67 @@ message GetTradeStatisticsReply { repeated TradeStatistics3 TradeStatistics = 1; } +/////////////////////////////////////////////////////////////////////////////////////////// +// Trades +/////////////////////////////////////////////////////////////////////////////////////////// + +service Trades { + rpc GetTrade (GetTradeRequest) returns (GetTradeReply) { + } + rpc TakeOffer (TakeOfferRequest) returns (TakeOfferReply) { + } + rpc ConfirmPaymentStarted (ConfirmPaymentStartedRequest) returns (ConfirmPaymentStartedReply) { + } + rpc ConfirmPaymentReceived (ConfirmPaymentReceivedRequest) returns (ConfirmPaymentReceivedReply) { + } +} + +message TakeOfferRequest { + string offerId = 1; + string paymentAccountId = 2; +} + +message TakeOfferReply { + TradeInfo trade = 1; +} + +message ConfirmPaymentStartedRequest { + string tradeId = 1; +} + +message ConfirmPaymentStartedReply { +} + +message ConfirmPaymentReceivedRequest { + string tradeId = 1; +} + +message ConfirmPaymentReceivedReply { +} + +message GetTradeRequest { + string tradeId = 1; +} + +message GetTradeReply { + TradeInfo trade = 1; +} + +message TradeInfo { + OfferInfo offer = 1; + string tradeId = 2; + string shortId = 3; + string state = 4; + string phase = 5; + string tradePeriodState = 6; + bool isDepositPublished = 7; + bool isDepositConfirmed = 8; + bool isFiatSent = 9; + bool isFiatReceived = 10; + bool isPayoutPublished = 11; + bool isWithdrawn = 12; +} + /////////////////////////////////////////////////////////////////////////////////////////// // Wallets ///////////////////////////////////////////////////////////////////////////////////////////