diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java index fe5bbceba58..1c223df2b7f 100644 --- a/core/src/main/java/bisq/core/api/CoreApi.java +++ b/core/src/main/java/bisq/core/api/CoreApi.java @@ -21,9 +21,7 @@ import bisq.core.api.model.BalancesInfo; import bisq.core.api.model.TxFeeRateInfo; import bisq.core.btc.wallet.TxBroadcaster; -import bisq.core.monetary.Price; import bisq.core.offer.Offer; -import bisq.core.offer.OfferPayload; import bisq.core.offer.OpenOffer; import bisq.core.payment.PaymentAccount; import bisq.core.payment.payload.PaymentMethod; @@ -36,7 +34,6 @@ import bisq.common.handlers.ErrorMessageHandler; import bisq.common.handlers.ResultHandler; -import org.bitcoinj.core.Coin; import org.bitcoinj.core.Transaction; import javax.inject.Inject; @@ -52,6 +49,8 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import static bisq.proto.grpc.EditOfferRequest.EditType; + /** * Provides high level interface to functionality of core Bisq features. * E.g. useful for different APIs to access data of different domains of Bisq. @@ -122,7 +121,7 @@ public Offer getOffer(String id) { return coreOffersService.getOffer(id); } - public Offer getMyOffer(String id) { + public OpenOffer getMyOffer(String id) { return coreOffersService.getMyOffer(id); } @@ -130,14 +129,10 @@ public List getOffers(String direction, String currencyCode) { return coreOffersService.getOffers(direction, currencyCode); } - public List getMyOffers(String direction, String currencyCode) { + public List getMyOffers(String direction, String currencyCode) { return coreOffersService.getMyOffers(direction, currencyCode); } - public OpenOffer getMyOpenOffer(String id) { - return coreOffersService.getMyOpenOffer(id); - } - public void createAnPlaceOffer(String currencyCode, String directionAsString, String priceAsString, @@ -164,26 +159,20 @@ public void createAnPlaceOffer(String currencyCode, resultHandler); } - public Offer editOffer(String offerId, - String currencyCode, - OfferPayload.Direction direction, - Price price, - boolean useMarketBasedPrice, - double marketPriceMargin, - Coin amount, - Coin minAmount, - double buyerSecurityDeposit, - PaymentAccount paymentAccount) { - return coreOffersService.editOffer(offerId, - currencyCode, - direction, - price, + public void editOffer(String offerId, + String priceAsString, + boolean useMarketBasedPrice, + double marketPriceMargin, + long triggerPrice, + int enable, + EditType editType) { + coreOffersService.editOffer(offerId, + priceAsString, useMarketBasedPrice, marketPriceMargin, - amount, - minAmount, - buyerSecurityDeposit, - paymentAccount); + triggerPrice, + enable, + editType); } public void cancelOffer(String id) { diff --git a/core/src/main/java/bisq/core/api/CoreOffersService.java b/core/src/main/java/bisq/core/api/CoreOffersService.java index e18a760c077..365072f1967 100644 --- a/core/src/main/java/bisq/core/api/CoreOffersService.java +++ b/core/src/main/java/bisq/core/api/CoreOffersService.java @@ -20,13 +20,16 @@ import bisq.core.monetary.Altcoin; import bisq.core.monetary.Price; import bisq.core.offer.CreateOfferService; +import bisq.core.offer.MutableOfferPayloadFields; import bisq.core.offer.Offer; import bisq.core.offer.OfferBookService; import bisq.core.offer.OfferFilter; +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; +import bisq.core.provider.price.PriceFeedService; import bisq.core.user.User; import bisq.common.crypto.KeyRing; @@ -54,7 +57,10 @@ import static bisq.core.locale.CurrencyUtil.isCryptoCurrency; import static bisq.core.offer.OfferPayload.Direction; import static bisq.core.offer.OfferPayload.Direction.BUY; +import static bisq.core.offer.OpenOffer.State.AVAILABLE; +import static bisq.core.offer.OpenOffer.State.DEACTIVATED; import static bisq.core.payment.PaymentAccountUtil.isPaymentAccountValidForOffer; +import static bisq.proto.grpc.EditOfferRequest.EditType; import static java.lang.String.format; import static java.util.Comparator.comparing; @@ -62,8 +68,11 @@ @Slf4j class CoreOffersService { - private final Supplier> priceComparator = () -> comparing(Offer::getPrice); - private final Supplier> reversePriceComparator = () -> comparing(Offer::getPrice).reversed(); + private final Supplier> priceComparator = () -> + comparing(Offer::getPrice); + + private final Supplier> openOfferPriceComparator = () -> + comparing(openOffer -> openOffer.getOffer().getPrice()); private final CoreContext coreContext; private final KeyRing keyRing; @@ -76,6 +85,7 @@ class CoreOffersService { private final OfferFilter offerFilter; private final OpenOfferManager openOfferManager; private final OfferUtil offerUtil; + private final PriceFeedService priceFeedService; private final User user; @Inject @@ -87,6 +97,7 @@ public CoreOffersService(CoreContext coreContext, OfferFilter offerFilter, OpenOfferManager openOfferManager, OfferUtil offerUtil, + PriceFeedService priceFeedService, User user) { this.coreContext = coreContext; this.keyRing = keyRing; @@ -96,6 +107,7 @@ public CoreOffersService(CoreContext coreContext, this.offerFilter = offerFilter; this.openOfferManager = openOfferManager; this.offerUtil = offerUtil; + this.priceFeedService = priceFeedService; this.user = user; } @@ -108,10 +120,10 @@ Offer getOffer(String id) { new IllegalStateException(format("offer with id '%s' not found", id))); } - Offer getMyOffer(String id) { - return offerBookService.getOffers().stream() + OpenOffer getMyOffer(String id) { + return openOfferManager.getObservableList().stream() .filter(o -> o.getId().equals(id)) - .filter(o -> o.isMyOffer(keyRing)) + .filter(o -> o.getOffer().isMyOffer(keyRing)) .findAny().orElseThrow(() -> new IllegalStateException(format("offer with id '%s' not found", id))); } @@ -125,11 +137,11 @@ List getOffers(String direction, String currencyCode) { .collect(Collectors.toList()); } - List getMyOffers(String direction, String currencyCode) { - return offerBookService.getOffers().stream() - .filter(o -> o.isMyOffer(keyRing)) - .filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode)) - .sorted(priceComparator(direction)) + List getMyOffers(String direction, String currencyCode) { + return openOfferManager.getObservableList().stream() + .filter(o -> o.getOffer().isMyOffer(keyRing)) + .filter(o -> offerMatchesDirectionAndCurrency(o.getOffer(), direction, currencyCode)) + .sorted(openOfferPriceComparator(direction)) .collect(Collectors.toList()); } @@ -137,7 +149,7 @@ OpenOffer getMyOpenOffer(String id) { return openOfferManager.getOpenOfferById(id) .filter(open -> open.getOffer().isMyOffer(keyRing)) .orElseThrow(() -> - new IllegalStateException(format("openoffer with id '%s' not found", id))); + new IllegalStateException(format("offer with id '%s' not found", id))); } // Create and place new offer. @@ -193,47 +205,56 @@ void createAndPlaceOffer(String currencyCode, } // Edit a placed offer. - Offer editOffer(String offerId, - String currencyCode, - Direction direction, - Price price, - boolean useMarketBasedPrice, - double marketPriceMargin, - Coin amount, - Coin minAmount, - double buyerSecurityDeposit, - PaymentAccount paymentAccount) { - Coin useDefaultTxFee = Coin.ZERO; - return createOfferService.createAndGetOffer(offerId, - direction, - currencyCode.toUpperCase(), - amount, - minAmount, - price, - useDefaultTxFee, - useMarketBasedPrice, - exactMultiply(marketPriceMargin, 0.01), - buyerSecurityDeposit, - paymentAccount); - } - - void cancelOffer(String id) { - Offer offer = getMyOffer(id); - openOfferManager.removeOffer(offer, + void editOffer(String offerId, + String editedPriceAsString, + boolean editedUseMarketBasedPrice, + double editedMarketPriceMargin, + long editedTriggerPrice, + int editedEnable, + EditType editType) { + OpenOffer openOffer = getMyOpenOffer(offerId); + new EditOfferValidator(openOffer, + editedPriceAsString, + editedUseMarketBasedPrice, + editedMarketPriceMargin, + editedTriggerPrice, + editType).validate(); + OfferPayload editedPayload = getMergedOfferPayload(openOffer, editedPriceAsString, + editedUseMarketBasedPrice, + editedMarketPriceMargin); + Offer editedOffer = new Offer(editedPayload); + priceFeedService.setCurrencyCode(openOffer.getOffer().getOfferPayload().getCurrencyCode()); + editedOffer.setPriceFeedService(priceFeedService); + editedOffer.setState(Offer.State.AVAILABLE); + openOfferManager.editOpenOfferStart(openOffer, () -> { + log.info("EditOpenOfferStart: offer {}", openOffer.getId()); }, errorMessage -> { - throw new IllegalStateException(errorMessage); + log.error(errorMessage); }); + // Client sent (sint32) newEnable, not a bool (with default=false). + // If newEnable = -1, do not change activation state + // If newEnable = 0, set state = AVAILABLE + // If newEnable = 1, set state = DEACTIVATED + OpenOffer.State newOfferState = editedEnable < 0 + ? openOffer.getState() + : editedEnable > 0 ? AVAILABLE : DEACTIVATED; + openOfferManager.editOpenOfferPublish(editedOffer, + editedTriggerPrice, + newOfferState, + () -> { + log.info("EditOpenOfferPublish: offer {}", openOffer.getId()); + }, + log::error); } - private void verifyPaymentAccountIsValidForNewOffer(Offer offer, PaymentAccount paymentAccount) { - if (!isPaymentAccountValidForOffer(offer, paymentAccount)) { - String error = format("cannot create %s offer with payment account %s", - offer.getOfferPayload().getCounterCurrencyCode(), - paymentAccount.getId()); - throw new IllegalStateException(error); - } + void cancelOffer(String id) { + OpenOffer openOffer = getMyOffer(id); + openOfferManager.removeOffer(openOffer.getOffer(), + () -> { + }, + log::error); } private void placeOffer(Offer offer, @@ -252,6 +273,39 @@ private void placeOffer(Offer offer, throw new IllegalStateException(offer.getErrorMessage()); } + private OfferPayload getMergedOfferPayload(OpenOffer openOffer, + String editedPriceAsString, + boolean editedUseMarketBasedPrice, + double editedMarketPriceMargin) { + // API supports editing price, marketPriceMargin, useMarketBasedPrice payload + // fields. API does not support editing payment acct or currency code fields. + Offer offer = openOffer.getOffer(); + String currencyCode = offer.getOfferPayload().getCurrencyCode(); + Price editedPrice = Price.valueOf(currencyCode, priceStringToLong(editedPriceAsString, currencyCode)); + MutableOfferPayloadFields mutableOfferPayloadFields = new MutableOfferPayloadFields( + editedPrice.getValue(), + exactMultiply(editedMarketPriceMargin, 0.01), + editedUseMarketBasedPrice, + offer.getOfferPayload().getBaseCurrencyCode(), + offer.getOfferPayload().getCounterCurrencyCode(), + offer.getPaymentMethod().getId(), + offer.getMakerPaymentAccountId(), + offer.getOfferPayload().getCountryCode(), + offer.getOfferPayload().getAcceptedCountryCodes(), + offer.getOfferPayload().getBankId(), + offer.getOfferPayload().getAcceptedBankIds()); + return offerUtil.getMergedOfferPayload(openOffer, mutableOfferPayloadFields); + } + + private void verifyPaymentAccountIsValidForNewOffer(Offer offer, PaymentAccount paymentAccount) { + if (!isPaymentAccountValidForOffer(offer, paymentAccount)) { + String error = format("cannot create %s offer with payment account %s", + offer.getOfferPayload().getCounterCurrencyCode(), + paymentAccount.getId()); + throw new IllegalStateException(error); + } + } + private boolean offerMatchesDirectionAndCurrency(Offer offer, String direction, String currencyCode) { @@ -261,11 +315,19 @@ private boolean offerMatchesDirectionAndCurrency(Offer offer, return offerOfWantedDirection && offerInWantedCurrency; } + private Comparator openOfferPriceComparator(String direction) { + // A buyer probably wants to see sell orders in price ascending order. + // A seller probably wants to see buy orders in price descending order. + return direction.equalsIgnoreCase(BUY.name()) + ? openOfferPriceComparator.get().reversed() + : openOfferPriceComparator.get(); + } + private Comparator priceComparator(String direction) { // A buyer probably wants to see sell orders in price ascending order. // A seller probably wants to see buy orders in price descending order. return direction.equalsIgnoreCase(BUY.name()) - ? reversePriceComparator.get() + ? priceComparator.get().reversed() : priceComparator.get(); } diff --git a/core/src/main/java/bisq/core/api/EditOfferValidator.java b/core/src/main/java/bisq/core/api/EditOfferValidator.java new file mode 100644 index 00000000000..f451e330811 --- /dev/null +++ b/core/src/main/java/bisq/core/api/EditOfferValidator.java @@ -0,0 +1,135 @@ +package bisq.core.api; + +import bisq.core.offer.OpenOffer; + +import bisq.proto.grpc.EditOfferRequest; + +import java.math.BigDecimal; + +import lombok.extern.slf4j.Slf4j; + +import static java.lang.String.format; + +@Slf4j +class EditOfferValidator { + + private final OpenOffer currentlyOpenOffer; + private final String editedPriceAsString; + private final boolean editedUseMarketBasedPrice; + private final double editedMarketPriceMargin; + private final long editedTriggerPrice; + private final EditOfferRequest.EditType editType; + + private final boolean isZeroEditedFixedPriceString; + private final boolean isZeroEditedMarketPriceMargin; + private final boolean isZeroEditedTriggerPrice; + + EditOfferValidator(OpenOffer currentlyOpenOffer, + String editedPriceAsString, + boolean editedUseMarketBasedPrice, + double editedMarketPriceMargin, + long editedTriggerPrice, + EditOfferRequest.EditType editType) { + this.currentlyOpenOffer = currentlyOpenOffer; + this.editedPriceAsString = editedPriceAsString; + this.editedUseMarketBasedPrice = editedUseMarketBasedPrice; + this.editedMarketPriceMargin = editedMarketPriceMargin; + this.editedTriggerPrice = editedTriggerPrice; + this.editType = editType; + + this.isZeroEditedFixedPriceString = new BigDecimal(editedPriceAsString).doubleValue() == 0; + this.isZeroEditedMarketPriceMargin = editedMarketPriceMargin == 0; + this.isZeroEditedTriggerPrice = editedTriggerPrice == 0; + } + + void validate() { + log.info("Verifying 'editoffer' params OK for editType {}", editType); + switch (editType) { + case ACTIVATION_STATE_ONLY: { + validateEditedActivationState(); + break; + } + case FIXED_PRICE_ONLY: + case FIXED_PRICE_AND_ACTIVATION_STATE: { + validateEditedFixedPrice(); + break; + } + case MKT_PRICE_MARGIN_ONLY: + case MKT_PRICE_MARGIN_AND_ACTIVATION_STATE: + case TRIGGER_PRICE_ONLY: + case TRIGGER_PRICE_AND_ACTIVATION_STATE: { + // Make sure the edited trigger price is OK, even if not being changed. + validateEditedTriggerPrice(); + // Continue, no break. + } + case MKT_PRICE_MARGIN_AND_TRIGGER_PRICE: + case MKT_PRICE_MARGIN_AND_TRIGGER_PRICE_AND_ACTIVATION_STATE: { + validateEditedMarketPriceMargin(); + break; + } + default: + break; + } + } + + private void validateEditedActivationState() { + if (!isZeroEditedFixedPriceString || !isZeroEditedMarketPriceMargin || !isZeroEditedTriggerPrice) + throw new IllegalStateException( + format("programmer error: cannot change fixed price (%s), " + + " mkt price margin (%s), or trigger price (%s) " + + " in offer with id '%s' when only changing activation state", + editedPriceAsString, + editedMarketPriceMargin, + editedTriggerPrice, + currentlyOpenOffer.getId())); + } + + private void validateEditedFixedPrice() { + if (currentlyOpenOffer.getOffer().isUseMarketBasedPrice()) + log.info("Attempting to change mkt price margin based offer with id '%s' to fixed price offer.", + currentlyOpenOffer.getId()); + + if (editedUseMarketBasedPrice) + throw new IllegalStateException( + format("programmer error: cannot change fixed price (%s)" + + " in mkt price based offer with id '%s'", + editedMarketPriceMargin, + currentlyOpenOffer.getId())); + + if (!isZeroEditedTriggerPrice) + throw new IllegalStateException( + format("programmer error: cannot change trigger price (%s)" + + " in offer with id '%s' when changing fixed price", + editedTriggerPrice, + currentlyOpenOffer.getId())); + + } + + private void validateEditedMarketPriceMargin() { + if (!currentlyOpenOffer.getOffer().isUseMarketBasedPrice()) + log.info("Attempting to change fixed price offer with id '%s' to mkt price margin based offer.", + currentlyOpenOffer.getId()); + + if (!editedUseMarketBasedPrice && !isZeroEditedTriggerPrice) + throw new IllegalStateException( + format("programmer error: cannot set a trigger price (%s)" + + " in fixed price offer with id '%s'", + editedTriggerPrice, + currentlyOpenOffer.getId())); + + if (!isZeroEditedFixedPriceString) + throw new IllegalStateException( + format("programmer error: cannot set fixed price (%s)" + + " in mkt price margin based offer with id '%s'", + editedPriceAsString, + currentlyOpenOffer.getId())); + } + + private void validateEditedTriggerPrice() { + if (editedTriggerPrice < 0) + throw new IllegalStateException( + format("programmer error: cannot set trigger price to a negative value" + + " in offer with id '%s'", + currentlyOpenOffer.getId())); + } +} 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 f8501f7df1f..e0588817041 100644 --- a/core/src/main/java/bisq/core/api/model/OfferInfo.java +++ b/core/src/main/java/bisq/core/api/model/OfferInfo.java @@ -18,6 +18,7 @@ package bisq.core.api.model; import bisq.core.offer.Offer; +import bisq.core.offer.OpenOffer; import bisq.common.Payload; @@ -61,7 +62,7 @@ public class OfferInfo implements Payload { private final String counterCurrencyCode; private final long date; private final String state; - + private final boolean isActivated; public OfferInfo(OfferInfoBuilder builder) { this.id = builder.id; @@ -87,17 +88,18 @@ public OfferInfo(OfferInfoBuilder builder) { this.counterCurrencyCode = builder.counterCurrencyCode; this.date = builder.date; this.state = builder.state; - + this.isActivated = builder.isActivated; } public static OfferInfo toOfferInfo(Offer offer) { return getOfferInfoBuilder(offer).build(); } - public static OfferInfo toOfferInfo(Offer offer, long triggerPrice) { - // The Offer does not have a triggerPrice attribute, so we get - // the base OfferInfoBuilder, then add the OpenOffer's triggerPrice. - return getOfferInfoBuilder(offer).withTriggerPrice(triggerPrice).build(); + public static OfferInfo toOfferInfo(OpenOffer openOffer) { + return getOfferInfoBuilder(openOffer.getOffer()) + .withTriggerPrice(openOffer.getTriggerPrice()) + .withIsActivated(!openOffer.isDeactivated()) + .build(); } private static OfferInfoBuilder getOfferInfoBuilder(Offer offer) { @@ -156,6 +158,7 @@ public bisq.proto.grpc.OfferInfo toProtoMessage() { .setCounterCurrencyCode(counterCurrencyCode) .setDate(date) .setState(state) + .setIsActivated(isActivated) .build(); } @@ -185,6 +188,7 @@ public static OfferInfo fromProto(bisq.proto.grpc.OfferInfo proto) { .withCounterCurrencyCode(proto.getCounterCurrencyCode()) .withDate(proto.getDate()) .withState(proto.getState()) + .withIsActivated(proto.getIsActivated()) .build(); } @@ -218,6 +222,7 @@ public static class OfferInfoBuilder { private String counterCurrencyCode; private long date; private String state; + private boolean isActivated; public OfferInfoBuilder withId(String id) { this.id = id; @@ -334,6 +339,11 @@ public OfferInfoBuilder withState(String state) { return this; } + public OfferInfoBuilder withIsActivated(boolean isActivated) { + this.isActivated = isActivated; + return this; + } + public OfferInfo build() { return new OfferInfo(this); } diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java index 54658e4c9a9..2917bae6289 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java @@ -26,6 +26,8 @@ import bisq.proto.grpc.CancelOfferRequest; import bisq.proto.grpc.CreateOfferReply; import bisq.proto.grpc.CreateOfferRequest; +import bisq.proto.grpc.EditOfferReply; +import bisq.proto.grpc.EditOfferRequest; import bisq.proto.grpc.GetMyOfferReply; import bisq.proto.grpc.GetMyOfferRequest; import bisq.proto.grpc.GetMyOffersReply; @@ -89,10 +91,9 @@ public void getOffer(GetOfferRequest req, public void getMyOffer(GetMyOfferRequest req, StreamObserver responseObserver) { try { - Offer offer = coreApi.getMyOffer(req.getId()); - OpenOffer openOffer = coreApi.getMyOpenOffer(req.getId()); + OpenOffer openOffer = coreApi.getMyOffer(req.getId()); var reply = GetMyOfferReply.newBuilder() - .setOffer(toOfferInfo(offer, openOffer.getTriggerPrice()).toProtoMessage()) + .setOffer(toOfferInfo(openOffer).toProtoMessage()) .build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -125,7 +126,8 @@ public void getMyOffers(GetMyOffersRequest req, StreamObserver responseObserver) { try { List result = coreApi.getMyOffers(req.getDirection(), req.getCurrencyCode()) - .stream().map(OfferInfo::toOfferInfo) + .stream() + .map(OfferInfo::toOfferInfo) .collect(Collectors.toList()); var reply = GetMyOffersReply.newBuilder() .addAllOffers(result.stream() @@ -170,6 +172,25 @@ public void createOffer(CreateOfferRequest req, } } + @Override + public void editOffer(EditOfferRequest req, + StreamObserver responseObserver) { + try { + coreApi.editOffer(req.getId(), + req.getPrice(), + req.getUseMarketBasedPrice(), + req.getMarketPriceMargin(), + req.getTriggerPrice(), + req.getEnable(), + req.getEditType()); + var reply = EditOfferReply.newBuilder().build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (Throwable cause) { + exceptionHandler.handleException(log, cause, responseObserver); + } + } + @Override public void cancelOffer(CancelOfferRequest req, StreamObserver responseObserver) { @@ -198,6 +219,7 @@ final Optional rateMeteringInterceptor() { put(getGetOffersMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS)); put(getGetMyOffersMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS)); put(getCreateOfferMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES)); + put(getEditOfferMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES)); put(getCancelOfferMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES)); }} )));