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/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/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/MutableOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java index 91506f00a11..966e54508b8 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java @@ -133,9 +133,9 @@ public abstract class MutableOfferDataModel extends OfferDataModel implements Bs protected boolean allowAmountUpdate = true; private final TradeStatisticsManager tradeStatisticsManager; - private final Predicate> isPositiveAmount = (c) -> c.get() != null && !c.get().isZero(); - private final Predicate> isPositivePrice = (p) -> p.get() != null && !p.get().isZero(); - private final Predicate> isPositiveVolume = (v) -> v.get() != null && !v.get().isZero(); + 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 @@ -517,7 +517,7 @@ long getMaxTradeLimit() { } void calculateVolume() { - if (isPositivePrice.test(price) && isPositiveAmount.test(amount)) { + if (isNonZeroPrice.test(price) && isNonZeroAmount.test(amount)) { try { Volume volumeByAmount = calculateVolumeForAmount(amount); @@ -533,7 +533,7 @@ void calculateVolume() { } void calculateMinVolume() { - if (isPositivePrice.test(price) && isPositiveAmount.test(minAmount)) { + if (isNonZeroPrice.test(price) && isNonZeroAmount.test(minAmount)) { try { Volume volumeByAmount = calculateVolumeForAmount(minAmount); @@ -557,7 +557,7 @@ else if (CurrencyUtil.isFiatCurrency(tradeCurrencyCode.get())) } void calculateAmount() { - if (isPositivePrice.test(price) && isPositiveVolume.test(volume) && allowAmountUpdate) { + if (isNonZeroPrice.test(price) && isNonZeroVolume.test(volume) && allowAmountUpdate) { try { Coin value = DisplayUtils.reduceTo4Decimals(price.get().getAmountByVolume(volume.get()), btcFormatter); if (paymentAccount.isHalCashAccount()) 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 ///////////////////////////////////////////////////////////////////////////////////////////