diff --git a/apitest/docs/api-beta-test-guide.md b/apitest/docs/api-beta-test-guide.md index 4a110a303ac..8c1b41112bc 100644 --- a/apitest/docs/api-beta-test-guide.md +++ b/apitest/docs/api-beta-test-guide.md @@ -408,8 +408,118 @@ The offer will be removed from other Bisq users' offer views, and paid transacti ### Editing an Existing Offer -Editing existing offers is not yet supported. You can cancel and re-create an offer, but paid transaction fees -for the canceled offer will be forfeited. +Offers you create can be edited in various ways: + +- Disable or re-enable an offer. +- Change an offer's price model and disable (or re-enable) it. +- Change a market price margin based offer to a fixed price offer. +- Change a market price margin based offer's price margin. +- Change, set, or remove a trigger price on a market price margin based offer. +- Change a market price margin based offer's price margin and trigger price. +- Change a market price margin based offer's price margin and remove its trigger price. +- Change a fixed price offer to a market price margin based offer. +- Change a fixed price offer's fixed price. + +_Note: the API does not support editing an offer's payment account._ + +The subsections below contain examples related to specific use cases. + +#### Enable and Disable Offer + +Existing offers you create can be disabled (removed from offer book) and re-enabled (re-published to offer book). + +To disable an offer: +``` +./bisq-cli --password=xyz --port=9998 editoffer \ + --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --enable=false +``` + +To enable an offer: +``` +./bisq-cli --password=xyz --port=9998 editoffer \ + --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --enable=true +``` + +#### Change Offer Pricing Model +The `editoffer` command can be used to change an existing market price margin based offer to a fixed price offer, +and vice-versa. + +##### Change Market Price Margin Based to Fixed Price Offer +Suppose you used `createoffer` to create a market price margin based offer as follows: +``` +$ ./bisq-cli --password=xyz --port=9998 createoffer \ + --payment-account=f3c1ec8b-9761-458d-b13d-9039c6892413 \ + --direction=SELL \ + --currency-code=JPY \ + --amount=0.125 \ + --market-price-margin=0.5 \ + --security-deposit=15.0 \ + --fee-currency=BSQ +``` +To change the market price margin based offer to a fixed price offer: +``` +./bisq-cli --password=xyz --port=9998 editoffer \ + --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --fixed-price=3960000.5555 +``` + +##### Change Fixed Price Offer to Market Price Margin Based Offer +Suppose you used `createoffer` to create a fixed price offer as follows: +``` +$ ./bisq-cli --password=xyz --port=9998 createoffer \ + --payment-account=f3c1ec8b-9761-458d-b13d-9039c6892413 \ + --direction=SELL \ + --currency-code=JPY \ + --amount=0.125 \ + --fixed-price=3960000.0000 \ + --security-deposit=15.0 \ + --fee-currency=BSQ +``` +To change the fixed price offer to a market price margin based offer: +``` +./bisq-cli --password=xyz --port=9998 editoffer \ + --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --market-price-margin=0.5 +``` +Alternatively, you can also set a trigger price on the re-published, market price margin based offer. +A trigger price on a SELL offer causes the offer to be automatically disabled when the market price +falls below the trigger price. In the `editoffer` example below, the SELL offer will be disabled when +the JPY market price falls below 3960000.0000. + +``` +./bisq-cli --password=xyz --port=9998 editoffer \ + --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --market-price-margin=0.5 \ + --trigger-price=3960000.0000 +``` +On a BUY offer, a trigger price causes the BUY offer to be automatically disabled when the market price +rises above the trigger price. + +_Note: Disabled offers never automatically re-enable; they can only be manually re-enabled via +`editoffer --offer-id= --enable=true`._ + +#### Remove Trigger Price +To remove a trigger price on a market price margin based offer, set the trigger price to 0: +``` +./bisq-cli --password=xyz --port=9998 editoffer \ + --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --trigger-price=0 +``` + +#### Change Disabled Offer's Pricing Model and Enable It +You can use `editoffer` to simultaneously change an offer's price details and disable or re-enable it. + +Suppose you have a disabled, fixed price offer, and want to change it to a market price margin based offer, set +a trigger price, and re-enable it: +``` +./bisq-cli --password=xyz --port=9998 editoffer \ + --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --market-price-margin=0.5 \ + --trigger-price=3960000.0000 \ + --enable=true +``` ### Taking Offers diff --git a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java index f0e95dd25f8..2d2e5fc6d73 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/AbstractOfferTest.java @@ -17,14 +17,13 @@ package bisq.apitest.method.offer; -import bisq.core.monetary.Altcoin; - import protobuf.PaymentAccount; -import org.bitcoinj.utils.Fiat; - import java.math.BigDecimal; +import java.util.function.BiFunction; +import java.util.function.Function; + import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -37,10 +36,7 @@ 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.locale.CurrencyUtil.isCryptoCurrency; -import static java.math.RoundingMode.HALF_UP; +import static bisq.common.util.MathUtils.exactMultiply; @@ -49,6 +45,10 @@ @Slf4j public abstract class AbstractOfferTest extends MethodTest { + protected static final int ACTIVATE_OFFER = 1; + protected static final int DEACTIVATE_OFFER = 0; + protected static final long NO_TRIGGER_PRICE = 0; + @Setter protected static boolean isLongRunningTest; @@ -67,6 +67,35 @@ public static void setUp() { } + // Mkt Price Margin value of offer returned from server is scaled down by 10^-2. + protected final Function scaledDownMktPriceMargin = (mktPriceMargin) -> + exactMultiply(mktPriceMargin, 0.01); + + // Price value of fiat offer returned from server will be scaled up by 10^4. + protected final Function scaledUpFiatOfferPrice = (price) -> { + BigDecimal factor = new BigDecimal(10).pow(4); + return price.multiply(factor).longValue(); + }; + + // Price value of altcoin offer returned from server will be scaled up by 10^8. + protected final Function scaledUpAltcoinOfferPrice = (altcoinPriceAsString) -> { + BigDecimal factor = new BigDecimal(10).pow(8); + BigDecimal priceAsBigDecimal = new BigDecimal(altcoinPriceAsString); + return priceAsBigDecimal.multiply(factor).longValue(); + }; + + protected final BiFunction calcPriceAsLong = (base, delta) -> { + var priceAsDouble = new BigDecimal(base).add(new BigDecimal(delta)).doubleValue(); + return Double.valueOf(exactMultiply(priceAsDouble, 10_000)).longValue(); + }; + + protected final BiFunction calcPriceAsString = (base, delta) -> { + var priceAsBigDecimal = new BigDecimal(Double.toString(base)) + .add(new BigDecimal(Double.toString(delta))); + return priceAsBigDecimal.toPlainString(); + }; + + @SuppressWarnings("ConstantConditions") public static void createBsqPaymentAccounts() { alicesBsqAcct = aliceClient.createCryptoCurrencyPaymentAccount("Alice's BSQ Account", BSQ, @@ -78,17 +107,6 @@ public static void createBsqPaymentAccounts() { false); } - protected double getScaledOfferPrice(double offerPrice, String currencyCode) { - int precision = isCryptoCurrency(currencyCode) ? Altcoin.SMALLEST_UNIT_EXPONENT : Fiat.SMALLEST_UNIT_EXPONENT; - return scaleDownByPowerOf10(offerPrice, precision); - } - - protected final double getPercentageDifference(double price1, double price2) { - return BigDecimal.valueOf(roundDouble((1 - (price1 / price2)), 5)) - .setScale(4, HALF_UP) - .doubleValue(); - } - @AfterAll public static void tearDown() { tearDownScaffold(); diff --git a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java index fe21e4aa8f2..8db313583cd 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CancelOfferTest.java @@ -54,7 +54,8 @@ public class CancelOfferTest extends AbstractOfferTest { 0.00, getDefaultBuyerSecurityDepositAsPercent(), paymentAccountId, - BSQ); + BSQ, + NO_TRIGGER_PRICE); }; @Test 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 94c2519d913..451bd1f2c18 100644 --- a/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java +++ b/apitest/src/test/java/bisq/apitest/method/offer/CreateOfferUsingMarketPriceMarginTest.java @@ -17,12 +17,18 @@ package bisq.apitest.method.offer; +import bisq.core.monetary.Altcoin; +import bisq.core.monetary.Price; import bisq.core.payment.PaymentAccount; import bisq.proto.grpc.OfferInfo; +import org.bitcoinj.utils.Fiat; + import java.text.DecimalFormat; +import java.math.BigDecimal; + import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Disabled; @@ -33,11 +39,14 @@ import static bisq.apitest.config.ApiTestConfig.BTC; import static bisq.cli.TableFormat.formatOfferTable; +import static bisq.common.util.MathUtils.roundDouble; import static bisq.common.util.MathUtils.scaleDownByPowerOf10; import static bisq.common.util.MathUtils.scaleUpByPowerOf10; import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; +import static bisq.core.locale.CurrencyUtil.isCryptoCurrency; import static java.lang.Math.abs; import static java.lang.String.format; +import static java.math.RoundingMode.HALF_UP; import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -45,6 +54,7 @@ import static protobuf.OfferPayload.Direction.BUY; import static protobuf.OfferPayload.Direction.SELL; +@SuppressWarnings("ConstantConditions") @Disabled @Slf4j @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @@ -68,7 +78,8 @@ public void testCreateUSDBTCBuyOffer5PctPriceMargin() { priceMarginPctInput, getDefaultBuyerSecurityDepositAsPercent(), usdAccount.getId(), - MAKER_FEE_CURRENCY_CODE); + MAKER_FEE_CURRENCY_CODE, + NO_TRIGGER_PRICE); log.info("OFFER #1:\n{}", formatOfferTable(singletonList(newOffer), "usd")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); @@ -109,7 +120,8 @@ public void testCreateNZDBTCBuyOfferMinus2PctPriceMargin() { priceMarginPctInput, getDefaultBuyerSecurityDepositAsPercent(), nzdAccount.getId(), - MAKER_FEE_CURRENCY_CODE); + MAKER_FEE_CURRENCY_CODE, + NO_TRIGGER_PRICE); log.info("OFFER #2:\n{}", formatOfferTable(singletonList(newOffer), "nzd")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); @@ -150,7 +162,8 @@ public void testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin() { priceMarginPctInput, getDefaultBuyerSecurityDepositAsPercent(), gbpAccount.getId(), - MAKER_FEE_CURRENCY_CODE); + MAKER_FEE_CURRENCY_CODE, + NO_TRIGGER_PRICE); log.info("OFFER #3:\n{}", formatOfferTable(singletonList(newOffer), "gbp")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); @@ -191,7 +204,8 @@ public void testCreateBRLBTCSellOffer6Point55PctPriceMargin() { priceMarginPctInput, getDefaultBuyerSecurityDepositAsPercent(), brlAccount.getId(), - MAKER_FEE_CURRENCY_CODE); + MAKER_FEE_CURRENCY_CODE, + NO_TRIGGER_PRICE); log.info("OFFER #4:\n{}", formatOfferTable(singletonList(newOffer), "brl")); String newOfferId = newOffer.getId(); assertNotEquals("", newOfferId); @@ -220,6 +234,30 @@ public void testCreateBRLBTCSellOffer6Point55PctPriceMargin() { assertCalculatedPriceIsCorrect(newOffer, priceMarginPctInput); } + @Test + @Order(5) + public void testCreateUSDBTCBuyOfferWithTriggerPrice() { + PaymentAccount usdAccount = createDummyF2FAccount(aliceClient, "US"); + double mktPriceAsDouble = aliceClient.getBtcPrice("usd"); + BigDecimal mktPrice = new BigDecimal(Double.toString(mktPriceAsDouble)); + BigDecimal triggerPrice = mktPrice.add(new BigDecimal("1000.9999")); + long triggerPriceAsLong = Price.parse("USD", triggerPrice.toString()).getValue(); + + var newOffer = aliceClient.createMarketBasedPricedOffer(BUY.name(), + "usd", + 10_000_000L, + 5_000_000L, + 0.0, + getDefaultBuyerSecurityDepositAsPercent(), + usdAccount.getId(), + MAKER_FEE_CURRENCY_CODE, + triggerPriceAsLong); + genBtcBlocksThenWait(1, 4000); // give time to add to offer book + newOffer = aliceClient.getMyOffer(newOffer.getId()); + log.info("OFFER #5:\n{}", formatOfferTable(singletonList(newOffer), "usd")); + assertEquals(triggerPriceAsLong, newOffer.getTriggerPrice()); + } + private void assertCalculatedPriceIsCorrect(OfferInfo offer, double priceMarginPctInput) { assertTrue(() -> { String counterCurrencyCode = offer.getCounterCurrencyCode(); @@ -239,6 +277,17 @@ private void assertCalculatedPriceIsCorrect(OfferInfo offer, double priceMarginP }); } + private double getPercentageDifference(double price1, double price2) { + return BigDecimal.valueOf(roundDouble((1 - (price1 / price2)), 5)) + .setScale(4, HALF_UP) + .doubleValue(); + } + + private double getScaledOfferPrice(double offerPrice, String currencyCode) { + int precision = isCryptoCurrency(currencyCode) ? Altcoin.SMALLEST_UNIT_EXPONENT : Fiat.SMALLEST_UNIT_EXPONENT; + return scaleDownByPowerOf10(offerPrice, precision); + } + private boolean isCalculatedPriceWithinErrorTolerance(double delta, double expectedDiffPct, double actualDiffPct, diff --git a/apitest/src/test/java/bisq/apitest/method/offer/EditOfferTest.java b/apitest/src/test/java/bisq/apitest/method/offer/EditOfferTest.java new file mode 100644 index 00000000000..a947044d07c --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/method/offer/EditOfferTest.java @@ -0,0 +1,644 @@ +/* + * 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.offer; + +import bisq.core.payment.PaymentAccount; + +import bisq.proto.grpc.OfferInfo; + +import io.grpc.StatusRuntimeException; + +import java.math.BigDecimal; + +import java.util.HashMap; +import java.util.Map; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Disabled; +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.ApiTestConfig.BSQ; +import static bisq.cli.TableFormat.formatOfferTable; +import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; +import static bisq.proto.grpc.EditOfferRequest.EditType.*; +import static java.lang.String.format; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; + +@SuppressWarnings("ALL") +@Disabled +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class EditOfferTest extends AbstractOfferTest { + + // Some test fixtures to reduce duplication. + private static final Map paymentAcctCache = new HashMap<>(); + private static final String DOLLAR = "USD"; + private static final String RUBLE = "RUB"; + private static final long AMOUNT = 10000000L; + + @Test + @Order(1) + public void testOfferDisableAndEnable() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("DE"); + OfferInfo originalOffer = createMktPricedOfferForEdit(BUY.name(), + "EUR", + paymentAcct.getId(), + 0.0, + NO_TRIGGER_PRICE); + log.info("ORIGINAL EUR OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "EUR")); + assertFalse(originalOffer.getIsActivated()); // Not activated until prep is done. + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + originalOffer = aliceClient.getMyOffer(originalOffer.getId()); + assertTrue(originalOffer.getIsActivated()); + // Disable offer + aliceClient.editOfferActivationState(originalOffer.getId(), DEACTIVATE_OFFER); + genBtcBlocksThenWait(1, 1500); // Wait for offer book removal. + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED EUR OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "EUR")); + assertFalse(editedOffer.getIsActivated()); + // Re-enable offer + aliceClient.editOfferActivationState(editedOffer.getId(), ACTIVATE_OFFER); + genBtcBlocksThenWait(1, 1500); // Wait for offer book re-entry. + editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED EUR OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "EUR")); + assertTrue(editedOffer.getIsActivated()); + + doSanityCheck(originalOffer, editedOffer); + } + + @Test + @Order(2) + public void testEditTriggerPrice() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("FI"); + OfferInfo originalOffer = createMktPricedOfferForEdit(SELL.name(), + "EUR", + paymentAcct.getId(), + 0.0, + NO_TRIGGER_PRICE); + log.info("ORIGINAL EUR OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "EUR")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + originalOffer = aliceClient.getMyOffer(originalOffer.getId()); + assertEquals(0 /*no trigger price*/, originalOffer.getTriggerPrice()); + + // Edit the offer's trigger price, nothing else. + var mktPrice = aliceClient.getBtcPrice("EUR"); + var delta = 5_000.00; + var newTriggerPriceAsLong = calcPriceAsLong.apply(mktPrice, delta); + + aliceClient.editOfferTriggerPrice(originalOffer.getId(), newTriggerPriceAsLong); + sleep(2500); // Wait for offer book re-entry. + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED EUR OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "EUR")); + assertEquals(newTriggerPriceAsLong, editedOffer.getTriggerPrice()); + assertTrue(editedOffer.getUseMarketBasedPrice()); + + doSanityCheck(originalOffer, editedOffer); + } + + @Test + @Order(3) + public void testSetTriggerPriceToNegativeValueShouldThrowException() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("FI"); + final OfferInfo originalOffer = createMktPricedOfferForEdit(SELL.name(), + "EUR", + paymentAcct.getId(), + 0.0, + NO_TRIGGER_PRICE); + log.info("ORIGINAL EUR OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "EUR")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + // Edit the offer's trigger price, set to -1, check error. + Throwable exception = assertThrows(StatusRuntimeException.class, () -> + aliceClient.editOfferTriggerPrice(originalOffer.getId(), -1L)); + String expectedExceptionMessage = + format("UNKNOWN: programmer error: cannot set trigger price to a negative value in offer with id '%s'", + originalOffer.getId()); + assertEquals(expectedExceptionMessage, exception.getMessage()); + } + + @Test + @Order(4) + public void testEditMktPriceMargin() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("US"); + var originalMktPriceMargin = new BigDecimal("0.1").doubleValue(); + OfferInfo originalOffer = createMktPricedOfferForEdit(SELL.name(), + DOLLAR, + paymentAcct.getId(), + originalMktPriceMargin, + NO_TRIGGER_PRICE); + log.info("ORIGINAL USD OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "USD")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + assertEquals(scaledDownMktPriceMargin.apply(originalMktPriceMargin), originalOffer.getMarketPriceMargin()); + // Edit the offer's price margin, nothing else. + var newMktPriceMargin = new BigDecimal("0.5").doubleValue(); + aliceClient.editOfferPriceMargin(originalOffer.getId(), newMktPriceMargin); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED USD OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "USD")); + assertEquals(scaledDownMktPriceMargin.apply(newMktPriceMargin), editedOffer.getMarketPriceMargin()); + + doSanityCheck(originalOffer, editedOffer); + } + + @Test + @Order(5) + public void testEditFixedPrice() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("RU"); + double mktPriceAsDouble = aliceClient.getBtcPrice(RUBLE); + String fixedPriceAsString = calcPriceAsString.apply(mktPriceAsDouble, 200_000.0000); + OfferInfo originalOffer = createFixedPricedOfferForEdit(BUY.name(), + RUBLE, + paymentAcct.getId(), + fixedPriceAsString); + log.info("ORIGINAL RUB OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "RUB")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + // Edit the offer's fixed price, nothing else. + String editedFixedPriceAsString = calcPriceAsString.apply(mktPriceAsDouble, 100_000.0000); + aliceClient.editOfferFixedPrice(originalOffer.getId(), editedFixedPriceAsString); + // Wait for edited offer to be removed from offer-book, edited, and re-published. + genBtcBlocksThenWait(1, 2500); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED RUB OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "RUB")); + var expectedNewFixedPrice = scaledUpFiatOfferPrice.apply(new BigDecimal(editedFixedPriceAsString)); + assertEquals(expectedNewFixedPrice, editedOffer.getPrice()); + assertFalse(editedOffer.getUseMarketBasedPrice()); + + doSanityCheck(originalOffer, editedOffer); + } + + @Test + @Order(6) + public void testEditFixedPriceAndDeactivation() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("RU"); + double mktPriceAsDouble = aliceClient.getBtcPrice(RUBLE); + String fixedPriceAsString = calcPriceAsString.apply(mktPriceAsDouble, 200_000.0000); + OfferInfo originalOffer = createFixedPricedOfferForEdit(BUY.name(), + RUBLE, + paymentAcct.getId(), + fixedPriceAsString); + log.info("ORIGINAL RUB OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "RUB")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + // Edit the offer's fixed price and deactivate it. + String editedFixedPriceAsString = calcPriceAsString.apply(mktPriceAsDouble, 100_000.0000); + aliceClient.editOffer(originalOffer.getId(), + editedFixedPriceAsString, + originalOffer.getUseMarketBasedPrice(), + 0.0, + NO_TRIGGER_PRICE, + DEACTIVATE_OFFER, + FIXED_PRICE_AND_ACTIVATION_STATE); + // Wait for edited offer to be removed from offer-book, edited, and re-published. + genBtcBlocksThenWait(1, 2500); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED RUB OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "RUB")); + var expectedNewFixedPrice = scaledUpFiatOfferPrice.apply(new BigDecimal(editedFixedPriceAsString)); + assertEquals(expectedNewFixedPrice, editedOffer.getPrice()); + assertFalse(editedOffer.getIsActivated()); + + doSanityCheck(originalOffer, editedOffer); + } + + @Test + @Order(7) + public void testEditMktPriceMarginAndDeactivation() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("US"); + + var originalMktPriceMargin = new BigDecimal("0.0").doubleValue(); + OfferInfo originalOffer = createMktPricedOfferForEdit(SELL.name(), + DOLLAR, + paymentAcct.getId(), + originalMktPriceMargin, + 0); + log.info("ORIGINAL USD OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "USD")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + originalOffer = aliceClient.getMyOffer(originalOffer.getId()); + assertEquals(scaledDownMktPriceMargin.apply(originalMktPriceMargin), originalOffer.getMarketPriceMargin()); + + // Edit the offer's price margin and trigger price, and deactivate it. + var newMktPriceMargin = new BigDecimal("1.50").doubleValue(); + aliceClient.editOffer(originalOffer.getId(), + "0.00", + originalOffer.getUseMarketBasedPrice(), + newMktPriceMargin, + 0, + DEACTIVATE_OFFER, + MKT_PRICE_MARGIN_AND_ACTIVATION_STATE); + // Wait for edited offer to be removed from offer-book, edited, and re-published. + genBtcBlocksThenWait(1, 2500); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED USD OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "USD")); + assertEquals(scaledDownMktPriceMargin.apply(newMktPriceMargin), editedOffer.getMarketPriceMargin()); + assertEquals(0, editedOffer.getTriggerPrice()); + assertFalse(editedOffer.getIsActivated()); + + doSanityCheck(originalOffer, editedOffer); + } + + @Test + @Order(8) + public void testEditMktPriceMarginAndTriggerPriceAndDeactivation() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("US"); + + var originalMktPriceMargin = new BigDecimal("0.0").doubleValue(); + var mktPriceAsDouble = aliceClient.getBtcPrice(DOLLAR); + var originalTriggerPriceAsLong = calcPriceAsLong.apply(mktPriceAsDouble, -5_000.0000); + + OfferInfo originalOffer = createMktPricedOfferForEdit(SELL.name(), + DOLLAR, + paymentAcct.getId(), + originalMktPriceMargin, + originalTriggerPriceAsLong); + log.info("ORIGINAL USD OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "USD")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + originalOffer = aliceClient.getMyOffer(originalOffer.getId()); + assertEquals(scaledDownMktPriceMargin.apply(originalMktPriceMargin), originalOffer.getMarketPriceMargin()); + assertEquals(originalTriggerPriceAsLong, originalOffer.getTriggerPrice()); + + // Edit the offer's price margin and trigger price, and deactivate it. + var newMktPriceMargin = new BigDecimal("0.1").doubleValue(); + var newTriggerPriceAsLong = calcPriceAsLong.apply(mktPriceAsDouble, -2_000.0000); + aliceClient.editOffer(originalOffer.getId(), + "0.00", + originalOffer.getUseMarketBasedPrice(), + newMktPriceMargin, + newTriggerPriceAsLong, + DEACTIVATE_OFFER, + MKT_PRICE_MARGIN_AND_TRIGGER_PRICE_AND_ACTIVATION_STATE); + // Wait for edited offer to be removed from offer-book, edited, and re-published. + genBtcBlocksThenWait(1, 2500); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED USD OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "USD")); + assertEquals(scaledDownMktPriceMargin.apply(newMktPriceMargin), editedOffer.getMarketPriceMargin()); + assertEquals(newTriggerPriceAsLong, editedOffer.getTriggerPrice()); + assertFalse(editedOffer.getIsActivated()); + + doSanityCheck(originalOffer, editedOffer); + } + + @Test + @Order(9) + public void testEditingFixedPriceInMktPriceMarginBasedOfferShouldThrowException() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("US"); + var originalMktPriceMargin = new BigDecimal("0.0").doubleValue(); + final OfferInfo originalOffer = createMktPricedOfferForEdit(SELL.name(), + DOLLAR, + paymentAcct.getId(), + originalMktPriceMargin, + NO_TRIGGER_PRICE); + log.info("ORIGINAL USD OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "USD")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + // Try to edit both the fixed price and mkt price margin. + var newMktPriceMargin = new BigDecimal("0.25").doubleValue(); + var newFixedPrice = "50000.0000"; + Throwable exception = assertThrows(StatusRuntimeException.class, () -> + aliceClient.editOffer(originalOffer.getId(), + newFixedPrice, + originalOffer.getUseMarketBasedPrice(), + newMktPriceMargin, + NO_TRIGGER_PRICE, + ACTIVATE_OFFER, + MKT_PRICE_MARGIN_ONLY)); + String expectedExceptionMessage = + format("UNKNOWN: programmer error: cannot set fixed price (%s) in" + + " mkt price margin based offer with id '%s'", + newFixedPrice, + originalOffer.getId()); + assertEquals(expectedExceptionMessage, exception.getMessage()); + } + + @Test + @Order(10) + public void testEditingTriggerPriceInFixedPriceOfferShouldThrowException() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("RU"); + double mktPriceAsDouble = aliceClient.getBtcPrice(RUBLE); + String fixedPriceAsString = calcPriceAsString.apply(mktPriceAsDouble, 200_000.0000); + OfferInfo originalOffer = createFixedPricedOfferForEdit(BUY.name(), + RUBLE, + paymentAcct.getId(), + fixedPriceAsString); + log.info("ORIGINAL RUB OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "RUB")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + long newTriggerPrice = 1000000L; + Throwable exception = assertThrows(StatusRuntimeException.class, () -> + aliceClient.editOfferTriggerPrice(originalOffer.getId(), newTriggerPrice)); + String expectedExceptionMessage = + format("UNKNOWN: programmer error: cannot set a trigger price (%s) in" + + " fixed price offer with id '%s'", + newTriggerPrice, + originalOffer.getId()); + assertEquals(expectedExceptionMessage, exception.getMessage()); + } + + @Test + @Order(11) + public void testChangeFixedPriceOfferToPriceMarginBasedOfferWithTriggerPrice() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("MX"); + double mktPriceAsDouble = aliceClient.getBtcPrice("MXN"); + String fixedPriceAsString = calcPriceAsString.apply(mktPriceAsDouble, 0.00); + OfferInfo originalOffer = createFixedPricedOfferForEdit(BUY.name(), + "MXN", + paymentAcct.getId(), + fixedPriceAsString); + log.info("ORIGINAL MXN OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "MXN")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + + // Change the offer to mkt price based and set a trigger price. + var newMktPriceMargin = new BigDecimal("0.05").doubleValue(); + var delta = 200_000.0000; // trigger price on buy offer is 200K above mkt price + var newTriggerPriceAsLong = calcPriceAsLong.apply(mktPriceAsDouble, delta); + aliceClient.editOffer(originalOffer.getId(), + "0.00", + true, + newMktPriceMargin, + newTriggerPriceAsLong, + ACTIVATE_OFFER, + MKT_PRICE_MARGIN_AND_TRIGGER_PRICE); + // Wait for edited offer to be removed from offer-book, edited, and re-published. + genBtcBlocksThenWait(1, 2500); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED MXN OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "MXN")); + assertTrue(editedOffer.getUseMarketBasedPrice()); + assertEquals(scaledDownMktPriceMargin.apply(newMktPriceMargin), editedOffer.getMarketPriceMargin()); + assertEquals(newTriggerPriceAsLong, editedOffer.getTriggerPrice()); + assertTrue(editedOffer.getIsActivated()); + + doSanityCheck(originalOffer, editedOffer); + } + + @Test + @Order(12) + public void testChangePriceMarginBasedOfferToFixedPriceOfferAndDeactivateIt() { + PaymentAccount paymentAcct = getOrCreatePaymentAccount("GB"); + double mktPriceAsDouble = aliceClient.getBtcPrice("GBP"); + var originalMktPriceMargin = new BigDecimal("0.25").doubleValue(); + var delta = 1_000.0000; // trigger price on sell offer is 1K below mkt price + var originalTriggerPriceAsLong = calcPriceAsLong.apply(mktPriceAsDouble, delta); + final OfferInfo originalOffer = createMktPricedOfferForEdit(SELL.name(), + "GBP", + paymentAcct.getId(), + originalMktPriceMargin, + originalTriggerPriceAsLong); + log.info("ORIGINAL GBP OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "GBP")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + + String fixedPriceAsString = calcPriceAsString.apply(mktPriceAsDouble, 0.00); + aliceClient.editOffer(originalOffer.getId(), + fixedPriceAsString, + false, + 0.00, + 0, + DEACTIVATE_OFFER, + FIXED_PRICE_AND_ACTIVATION_STATE); + // Wait for edited offer to be removed from offer-book, edited, and re-published. + genBtcBlocksThenWait(1, 2500); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED GBP OFFER:\n{}", formatOfferTable(singletonList(editedOffer), "GBP")); + assertEquals(scaledUpFiatOfferPrice.apply(new BigDecimal(fixedPriceAsString)), editedOffer.getPrice()); + assertFalse(editedOffer.getUseMarketBasedPrice()); + assertEquals(0.00, editedOffer.getMarketPriceMargin()); + assertEquals(0, editedOffer.getTriggerPrice()); + assertFalse(editedOffer.getIsActivated()); + } + + @Test + @Order(13) + public void testChangeFixedPricedBsqOfferToPriceMarginBasedOfferShouldThrowException() { + createBsqPaymentAccounts(); + OfferInfo originalOffer = aliceClient.createFixedPricedOffer(BUY.name(), + BSQ, + 100_000_000L, + 100_000_000L, + "0.00005", // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + BSQ); + log.info("ORIGINAL BSQ OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "BSQ")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + Throwable exception = assertThrows(StatusRuntimeException.class, () -> + aliceClient.editOffer(originalOffer.getId(), + "0.00", + true, + 0.1, + 0, + ACTIVATE_OFFER, + MKT_PRICE_MARGIN_ONLY)); + String expectedExceptionMessage = format("UNKNOWN: cannot set mkt price margin or" + + " trigger price on fixed price altcoin offer with id '%s'", + originalOffer.getId()); + assertEquals(expectedExceptionMessage, exception.getMessage()); + } + + @Test + @Order(14) + public void testEditTriggerPriceOnFixedPriceBsqOfferShouldThrowException() { + createBsqPaymentAccounts(); + OfferInfo originalOffer = aliceClient.createFixedPricedOffer(BUY.name(), + BSQ, + 100_000_000L, + 100_000_000L, + "0.00005", // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + BSQ); + log.info("ORIGINAL BSQ OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "BSQ")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + var newTriggerPriceAsLong = calcPriceAsLong.apply(0.00005, 0.00); + Throwable exception = assertThrows(StatusRuntimeException.class, () -> + aliceClient.editOffer(originalOffer.getId(), + "0.00", + false, + 0.1, + newTriggerPriceAsLong, + ACTIVATE_OFFER, + TRIGGER_PRICE_ONLY)); + String expectedExceptionMessage = format("UNKNOWN: cannot set mkt price margin or" + + " trigger price on fixed price altcoin offer with id '%s'", + originalOffer.getId()); + assertEquals(expectedExceptionMessage, exception.getMessage()); + } + + @Test + @Order(15) + public void testEditFixedPriceOnBsqOffer() { + createBsqPaymentAccounts(); + String fixedPriceAsString = "0.00005"; // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ + final OfferInfo originalOffer = aliceClient.createFixedPricedOffer(BUY.name(), + BSQ, + 100_000_000L, + 100_000_000L, + fixedPriceAsString, + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + BSQ); + log.info("ORIGINAL BSQ OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "BSQ")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + String newFixedPriceAsString = "0.00003111"; + aliceClient.editOffer(originalOffer.getId(), + newFixedPriceAsString, + false, + 0.0, + 0, + ACTIVATE_OFFER, + FIXED_PRICE_ONLY); + // Wait for edited offer to be edited and removed from offer-book. + genBtcBlocksThenWait(1, 2500); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED BSQ OFFER:\n{}", formatOfferTable(singletonList(editedOffer), BSQ)); + assertEquals(scaledUpAltcoinOfferPrice.apply(newFixedPriceAsString), editedOffer.getPrice()); + assertTrue(editedOffer.getIsActivated()); + assertFalse(editedOffer.getUseMarketBasedPrice()); + assertEquals(0.00, editedOffer.getMarketPriceMargin()); + assertEquals(0, editedOffer.getTriggerPrice()); + } + + @Test + @Order(16) + public void testDisableBsqOffer() { + createBsqPaymentAccounts(); + String fixedPriceAsString = "0.00005"; // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ + final OfferInfo originalOffer = aliceClient.createFixedPricedOffer(BUY.name(), + BSQ, + 100_000_000L, + 100_000_000L, + fixedPriceAsString, + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + BSQ); + log.info("ORIGINAL BSQ OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "BSQ")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + aliceClient.editOffer(originalOffer.getId(), + fixedPriceAsString, + false, + 0.0, + 0, + DEACTIVATE_OFFER, + ACTIVATION_STATE_ONLY); + // Wait for edited offer to be removed from offer-book. + genBtcBlocksThenWait(1, 2500); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED BSQ OFFER:\n{}", formatOfferTable(singletonList(editedOffer), BSQ)); + assertFalse(editedOffer.getIsActivated()); + assertEquals(scaledUpAltcoinOfferPrice.apply(fixedPriceAsString), editedOffer.getPrice()); + assertFalse(editedOffer.getUseMarketBasedPrice()); + assertEquals(0.00, editedOffer.getMarketPriceMargin()); + assertEquals(0, editedOffer.getTriggerPrice()); + } + + @Test + @Order(17) + public void testEditFixedPriceAndDisableBsqOffer() { + createBsqPaymentAccounts(); + String fixedPriceAsString = "0.00005"; // FIXED PRICE IN BTC (satoshis) FOR 1 BSQ + final OfferInfo originalOffer = aliceClient.createFixedPricedOffer(BUY.name(), + BSQ, + 100_000_000L, + 100_000_000L, + fixedPriceAsString, + getDefaultBuyerSecurityDepositAsPercent(), + alicesBsqAcct.getId(), + BSQ); + log.info("ORIGINAL BSQ OFFER:\n{}", formatOfferTable(singletonList(originalOffer), "BSQ")); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + String newFixedPriceAsString = "0.000045"; + aliceClient.editOffer(originalOffer.getId(), + newFixedPriceAsString, + false, + 0.0, + 0, + DEACTIVATE_OFFER, + FIXED_PRICE_AND_ACTIVATION_STATE); + // Wait for edited offer to be edited and removed from offer-book. + genBtcBlocksThenWait(1, 2500); + OfferInfo editedOffer = aliceClient.getMyOffer(originalOffer.getId()); + log.info("EDITED BSQ OFFER:\n{}", formatOfferTable(singletonList(editedOffer), BSQ)); + assertFalse(editedOffer.getIsActivated()); + assertEquals(scaledUpAltcoinOfferPrice.apply(newFixedPriceAsString), editedOffer.getPrice()); + assertFalse(editedOffer.getUseMarketBasedPrice()); + assertEquals(0.00, editedOffer.getMarketPriceMargin()); + assertEquals(0, editedOffer.getTriggerPrice()); + } + + private OfferInfo createMktPricedOfferForEdit(String direction, + String currencyCode, + String paymentAccountId, + double marketPriceMargin, + long triggerPrice) { + return aliceClient.createMarketBasedPricedOffer(direction, + currencyCode, + AMOUNT, + AMOUNT, + marketPriceMargin, + getDefaultBuyerSecurityDepositAsPercent(), + paymentAccountId, + BSQ, + triggerPrice); + } + + private OfferInfo createFixedPricedOfferForEdit(String direction, + String currencyCode, + String paymentAccountId, + String priceAsString) { + return aliceClient.createFixedPricedOffer(direction, + currencyCode, + AMOUNT, + AMOUNT, + priceAsString, + getDefaultBuyerSecurityDepositAsPercent(), + paymentAccountId, + BSQ); + } + + private void doSanityCheck(OfferInfo originalOffer, OfferInfo editedOffer) { + // Assert some of the immutable offer fields are unchanged. + assertEquals(originalOffer.getDirection(), editedOffer.getDirection()); + assertEquals(originalOffer.getAmount(), editedOffer.getAmount()); + assertEquals(originalOffer.getMinAmount(), editedOffer.getMinAmount()); + assertEquals(originalOffer.getTxFee(), editedOffer.getTxFee()); + assertEquals(originalOffer.getMakerFee(), editedOffer.getMakerFee()); + assertEquals(originalOffer.getPaymentAccountId(), editedOffer.getPaymentAccountId()); + assertEquals(originalOffer.getDate(), editedOffer.getDate()); + if (originalOffer.getDirection().equals(BUY.name())) + assertEquals(originalOffer.getBuyerSecurityDeposit(), editedOffer.getBuyerSecurityDeposit()); + else + assertEquals(originalOffer.getSellerSecurityDeposit(), editedOffer.getSellerSecurityDeposit()); + } + + private PaymentAccount getOrCreatePaymentAccount(String countryCode) { + if (paymentAcctCache.containsKey(countryCode)) { + return paymentAcctCache.get(countryCode); + } else { + PaymentAccount paymentAcct = createDummyF2FAccount(aliceClient, countryCode); + paymentAcctCache.put(countryCode, paymentAcct); + return paymentAcct; + } + } + + @AfterAll + public static void clearPaymentAcctCache() { + paymentAcctCache.clear(); + } +} diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java index 93d9b1b9c8b..8f03520b525 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferTest.java @@ -72,7 +72,8 @@ public void testTakeAlicesBuyOffer(final TestInfo testInfo) { 0.00, getDefaultBuyerSecurityDepositAsPercent(), alicesUsdAccount.getId(), - TRADE_FEE_CURRENCY_CODE); + TRADE_FEE_CURRENCY_CODE, + NO_TRIGGER_PRICE); var offerId = alicesOffer.getId(); assertFalse(alicesOffer.getIsCurrencyForMakerFeeBtc()); diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferWithNationalBankAcctTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferWithNationalBankAcctTest.java index 3868fffc300..1035875010e 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferWithNationalBankAcctTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeBuyBTCOfferWithNationalBankAcctTest.java @@ -103,7 +103,8 @@ public void testTakeAlicesBuyOffer(final TestInfo testInfo) { 0.00, getDefaultBuyerSecurityDepositAsPercent(), alicesPaymentAccount.getId(), - TRADE_FEE_CURRENCY_CODE); + TRADE_FEE_CURRENCY_CODE, + NO_TRIGGER_PRICE); var offerId = alicesOffer.getId(); assertFalse(alicesOffer.getIsCurrencyForMakerFeeBtc()); diff --git a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java index ece3432123b..c4abd90934b 100644 --- a/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java +++ b/apitest/src/test/java/bisq/apitest/method/trade/TakeSellBTCOfferTest.java @@ -75,7 +75,8 @@ public void testTakeAlicesSellOffer(final TestInfo testInfo) { 0.00, getDefaultBuyerSecurityDepositAsPercent(), alicesUsdAccount.getId(), - TRADE_FEE_CURRENCY_CODE); + TRADE_FEE_CURRENCY_CODE, + NO_TRIGGER_PRICE); var offerId = alicesOffer.getId(); assertTrue(alicesOffer.getIsCurrencyForMakerFeeBtc()); diff --git a/apitest/src/test/java/bisq/apitest/scenario/LongRunningOfferDeactivationTest.java b/apitest/src/test/java/bisq/apitest/scenario/LongRunningOfferDeactivationTest.java new file mode 100644 index 00000000000..e7f09247a86 --- /dev/null +++ b/apitest/src/test/java/bisq/apitest/scenario/LongRunningOfferDeactivationTest.java @@ -0,0 +1,167 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.apitest.scenario; + +import bisq.core.payment.PaymentAccount; + +import bisq.proto.grpc.OfferInfo; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.condition.EnabledIf; + +import static bisq.apitest.config.ApiTestConfig.BTC; +import static bisq.cli.CurrencyFormat.formatPrice; +import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; +import static java.lang.System.getenv; +import static org.junit.jupiter.api.Assertions.fail; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; + + + +import bisq.apitest.method.offer.AbstractOfferTest; + +/** + * Used to verify trigger based, automatic offer deactivation works. + * Disabled by default. + * Set ENV or IDE-ENV LONG_RUNNING_OFFER_DEACTIVATION_TEST_ENABLED=true to run. + */ +@EnabledIf("envLongRunningTestEnabled") +@Slf4j +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class LongRunningOfferDeactivationTest extends AbstractOfferTest { + + private static final int MAX_ITERATIONS = 500; + + @Test + @Order(1) + public void testSellOfferAutoDisable(final TestInfo testInfo) { + PaymentAccount paymentAcct = createDummyF2FAccount(aliceClient, "US"); + double mktPriceAsDouble = aliceClient.getBtcPrice("USD"); + long triggerPrice = calcPriceAsLong.apply(mktPriceAsDouble, -50.0000); + log.info("Current USD mkt price = {} Trigger Price = {}", mktPriceAsDouble, formatPrice(triggerPrice)); + OfferInfo offer = aliceClient.createMarketBasedPricedOffer(SELL.name(), + "USD", + 1_000_000, + 1_000_000, + 0.00, + getDefaultBuyerSecurityDepositAsPercent(), + paymentAcct.getId(), + BTC, + triggerPrice); + log.info("SELL offer {} created with margin based price {}.", + offer.getId(), + formatPrice(offer.getPrice())); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + + offer = aliceClient.getMyOffer(offer.getId()); // Offer has trigger price now. + log.info("SELL offer should be automatically disabled when mkt price falls below {}.", + formatPrice(offer.getTriggerPrice())); + + int numIterations = 0; + while (++numIterations < MAX_ITERATIONS) { + offer = aliceClient.getMyOffer(offer.getId()); + + var mktPrice = aliceClient.getBtcPrice("USD"); + if (offer.getIsActivated()) { + log.info("Offer still enabled at mkt price {} > {} trigger price", + mktPrice, + formatPrice(offer.getTriggerPrice())); + sleep(1000 * 60); // 60s + } else { + log.info("Successful test completion after offer disabled at mkt price {} < {} trigger price.", + mktPrice, + formatPrice(offer.getTriggerPrice())); + break; + } + if (numIterations == MAX_ITERATIONS) + fail("Offer never disabled"); + + genBtcBlocksThenWait(1, 0); + } + } + + @Test + @Order(2) + public void testBuyOfferAutoDisable(final TestInfo testInfo) { + PaymentAccount paymentAcct = createDummyF2FAccount(aliceClient, "US"); + double mktPriceAsDouble = aliceClient.getBtcPrice("USD"); + long triggerPrice = calcPriceAsLong.apply(mktPriceAsDouble, 50.0000); + log.info("Current USD mkt price = {} Trigger Price = {}", mktPriceAsDouble, formatPrice(triggerPrice)); + OfferInfo offer = aliceClient.createMarketBasedPricedOffer(BUY.name(), + "USD", + 1_000_000, + 1_000_000, + 0.00, + getDefaultBuyerSecurityDepositAsPercent(), + paymentAcct.getId(), + BTC, + triggerPrice); + log.info("BUY offer {} created with margin based price {}.", + offer.getId(), + formatPrice(offer.getPrice())); + genBtcBlocksThenWait(1, 2500); // Wait for offer book entry. + + offer = aliceClient.getMyOffer(offer.getId()); // Offer has trigger price now. + log.info("BUY offer should be automatically disabled when mkt price rises above {}.", + formatPrice(offer.getTriggerPrice())); + + int numIterations = 0; + while (++numIterations < MAX_ITERATIONS) { + offer = aliceClient.getMyOffer(offer.getId()); + + var mktPrice = aliceClient.getBtcPrice("USD"); + if (offer.getIsActivated()) { + log.info("Offer still enabled at mkt price {} < {} trigger price", + mktPrice, + formatPrice(offer.getTriggerPrice())); + sleep(1000 * 60); // 60s + } else { + log.info("Successful test completion after offer disabled at mkt price {} > {} trigger price.", + mktPrice, + formatPrice(offer.getTriggerPrice())); + break; + } + if (numIterations == MAX_ITERATIONS) + fail("Offer never disabled"); + + genBtcBlocksThenWait(1, 0); + } + } + + protected static boolean envLongRunningTestEnabled() { + String envName = "LONG_RUNNING_OFFER_DEACTIVATION_TEST_ENABLED"; + String envX = getenv(envName); + if (envX != null) { + log.info("Enabled, found {}.", envName); + return true; + } else { + log.info("Skipped, no environment variable {} defined.", envName); + log.info("To enable on Mac OS or Linux:" + + "\tIf running in terminal, export LONG_RUNNING_OFFER_DEACTIVATION_TEST_ENABLED=true in bash shell." + + "\tIf running in Intellij, set LONG_RUNNING_OFFER_DEACTIVATION_TEST_ENABLED=true in launcher's Environment variables field."); + return false; + } + } +} diff --git a/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java b/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java index 15c11e65b49..41ac197f1b5 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java +++ b/apitest/src/test/java/bisq/apitest/scenario/OfferTest.java @@ -32,6 +32,7 @@ import bisq.apitest.method.offer.CreateBSQOffersTest; import bisq.apitest.method.offer.CreateOfferUsingFixedPriceTest; import bisq.apitest.method.offer.CreateOfferUsingMarketPriceMarginTest; +import bisq.apitest.method.offer.EditOfferTest; import bisq.apitest.method.offer.ValidateCreateOfferTest; @Slf4j @@ -71,11 +72,12 @@ public void testCreateOfferUsingMarketPriceMargin() { test.testCreateNZDBTCBuyOfferMinus2PctPriceMargin(); test.testCreateGBPBTCSellOfferMinus1Point5PctPriceMargin(); test.testCreateBRLBTCSellOffer6Point55PctPriceMargin(); + test.testCreateUSDBTCBuyOfferWithTriggerPrice(); } @Test @Order(5) - public void testCreateBSQOffersTest() { + public void testCreateBSQOffers() { CreateBSQOffersTest test = new CreateBSQOffersTest(); CreateBSQOffersTest.createBsqPaymentAccounts(); test.testCreateBuy1BTCFor20KBSQOffer(); @@ -85,4 +87,30 @@ public void testCreateBSQOffersTest() { test.testGetAllMyBsqOffers(); test.testGetAvailableBsqOffers(); } + + @Test + @Order(6) + public void testEditOffer() { + EditOfferTest test = new EditOfferTest(); + // Edit fiat offer tests + test.testOfferDisableAndEnable(); + test.testEditTriggerPrice(); + test.testSetTriggerPriceToNegativeValueShouldThrowException(); + test.testEditMktPriceMargin(); + test.testEditFixedPrice(); + test.testEditFixedPriceAndDeactivation(); + test.testEditMktPriceMarginAndDeactivation(); + test.testEditMktPriceMarginAndTriggerPriceAndDeactivation(); + test.testEditingFixedPriceInMktPriceMarginBasedOfferShouldThrowException(); + test.testEditingTriggerPriceInFixedPriceOfferShouldThrowException(); + test.testChangeFixedPriceOfferToPriceMarginBasedOfferWithTriggerPrice(); + test.testChangePriceMarginBasedOfferToFixedPriceOfferAndDeactivateIt(); + test.testChangeFixedPriceOfferToPriceMarginBasedOfferWithTriggerPrice(); + // Edit bsq offer tests + test.testChangeFixedPricedBsqOfferToPriceMarginBasedOfferShouldThrowException(); + test.testEditTriggerPriceOnFixedPriceBsqOfferShouldThrowException(); + test.testEditFixedPriceOnBsqOffer(); + test.testDisableBsqOffer(); + test.testEditFixedPriceAndDisableBsqOffer(); + } } diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java b/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java index c34dc14d28b..062ee742b19 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/BotClient.java @@ -39,11 +39,6 @@ /** * Convenience GrpcClient wrapper for bots using gRPC services. - * - * TODO Consider if the duplication smell is bad enough to force a BotClient user - * to use the GrpcClient instead (and delete this class). But right now, I think it is - * OK because moving some of the non-gRPC related methods to GrpcClient is even smellier. - * */ @SuppressWarnings({"JavaDoc", "unused"}) @Slf4j @@ -134,7 +129,8 @@ public OfferInfo createOfferAtMarketBasedPrice(PaymentAccount paymentAccount, long minAmountInSatoshis, double priceMarginAsPercent, double securityDepositAsPercent, - String feeCurrency) { + String feeCurrency, + long triggerPrice) { return grpcClient.createMarketBasedPricedOffer(direction, currencyCode, amountInSatoshis, @@ -142,7 +138,8 @@ public OfferInfo createOfferAtMarketBasedPrice(PaymentAccount paymentAccount, priceMarginAsPercent, securityDepositAsPercent, paymentAccount.getId(), - feeCurrency); + feeCurrency, + triggerPrice); } /** diff --git a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java index 1942f8ad073..de728aa76e9 100644 --- a/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java +++ b/apitest/src/test/java/bisq/apitest/scenario/bot/RandomOffer.java @@ -33,7 +33,7 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import static bisq.cli.CurrencyFormat.formatMarketPrice; +import static bisq.cli.CurrencyFormat.formatInternalFiatPrice; import static bisq.cli.CurrencyFormat.formatSatoshis; import static bisq.common.util.MathUtils.scaleDownByPowerOf10; import static bisq.core.btc.wallet.Restrictions.getDefaultBuyerSecurityDepositAsPercent; @@ -128,7 +128,8 @@ public RandomOffer create() throws InvalidRandomOfferException { minAmount, priceMargin, getDefaultBuyerSecurityDepositAsPercent(), - feeCurrency); + feeCurrency, + 0 /*no trigger price*/); } else { this.offer = botClient.createOfferAtFixedPrice(paymentAccount, direction, @@ -167,11 +168,11 @@ private void printDescription() { log.info(description); if (useMarketBasedPrice) { log.info("Offer Price Margin = {}%", priceMargin); - log.info("Expected Offer Price = {} {}", formatMarketPrice(Double.parseDouble(fixedOfferPrice)), currencyCode); + log.info("Expected Offer Price = {} {}", formatInternalFiatPrice(Double.parseDouble(fixedOfferPrice)), currencyCode); } else { log.info("Fixed Offer Price = {} {}", fixedOfferPrice, currencyCode); } - log.info("Current Market Price = {} {}", formatMarketPrice(currentMarketPrice), currencyCode); + log.info("Current Market Price = {} {}", formatInternalFiatPrice(currentMarketPrice), currencyCode); } } diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index 95bfd0d7b84..bf3978675c6 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -39,10 +39,7 @@ import lombok.extern.slf4j.Slf4j; -import static bisq.cli.CurrencyFormat.formatMarketPrice; -import static bisq.cli.CurrencyFormat.formatTxFeeRateInfo; -import static bisq.cli.CurrencyFormat.toSatoshis; -import static bisq.cli.CurrencyFormat.toSecurityDepositAsPct; +import static bisq.cli.CurrencyFormat.*; import static bisq.cli.Method.*; import static bisq.cli.TableFormat.*; import static bisq.cli.opts.OptLabel.*; @@ -59,6 +56,7 @@ import bisq.cli.opts.CreateCryptoCurrencyPaymentAcctOptionParser; import bisq.cli.opts.CreateOfferOptionParser; import bisq.cli.opts.CreatePaymentAcctOptionParser; +import bisq.cli.opts.EditOfferOptionParser; import bisq.cli.opts.GetAddressBalanceOptionParser; import bisq.cli.opts.GetBTCMarketPriceOptionParser; import bisq.cli.opts.GetBalanceOptionParser; @@ -200,7 +198,7 @@ public static void run(String[] args) { } var currencyCode = opts.getCurrencyCode(); var price = client.getBtcPrice(currencyCode); - out.println(formatMarketPrice(price)); + out.println(formatInternalFiatPrice(price)); return; } case getfundingaddresses: { @@ -337,6 +335,7 @@ public static void run(String[] args) { var marketPriceMargin = opts.getMktPriceMarginAsBigDecimal(); var securityDeposit = toSecurityDepositAsPct(opts.getSecurityDeposit()); var makerFeeCurrencyCode = opts.getMakerFeeCurrencyCode(); + var triggerPrice = 0; // Cannot be defined until offer is in book. var offer = client.createOffer(direction, currencyCode, amount, @@ -346,10 +345,34 @@ public static void run(String[] args) { marketPriceMargin.doubleValue(), securityDeposit, paymentAcctId, - makerFeeCurrencyCode); + makerFeeCurrencyCode, + triggerPrice); out.println(formatOfferTable(singletonList(offer), currencyCode)); return; } + case editoffer: { + var opts = new EditOfferOptionParser(args).parse(); + if (opts.isForHelp()) { + out.println(client.getMethodHelp(method)); + return; + } + var offerId = opts.getOfferId(); + var fixedPrice = opts.getFixedPrice(); + var isUsingMktPriceMargin = opts.isUsingMktPriceMargin(); + var marketPriceMargin = opts.getMktPriceMarginAsBigDecimal(); + var triggerPrice = toInternalFiatPrice(opts.getTriggerPriceAsBigDecimal()); + var enable = opts.getEnableAsSignedInt(); + var editOfferType = opts.getOfferEditType(); + client.editOffer(offerId, + fixedPrice, + isUsingMktPriceMargin, + marketPriceMargin.doubleValue(), + triggerPrice, + enable, + editOfferType); + out.println("offer has been edited"); + return; + } case canceloffer: { var opts = new CancelOfferOptionParser(args).parse(); if (opts.isForHelp()) { @@ -754,6 +777,13 @@ private static void printHelp(OptionParser parser, @SuppressWarnings("SameParame stream.format(rowFormat, "", "--fixed-price= | --market-price=margin= \\", ""); stream.format(rowFormat, "", "--security-deposit= \\", ""); stream.format(rowFormat, "", "[--fee-currency=]", ""); + stream.format(rowFormat, "", "[--trigger-price=]", ""); + stream.println(); + stream.format(rowFormat, editoffer.name(), "--offer-id= \\", "Edit offer with id"); + stream.format(rowFormat, "", "[--fixed-price=] \\", ""); + stream.format(rowFormat, "", "[--market-price=margin=] \\", ""); + stream.format(rowFormat, "", "[--trigger-price=] \\", ""); + stream.format(rowFormat, "", "[--enabled=]", ""); stream.println(); stream.format(rowFormat, canceloffer.name(), "--offer-id=", "Cancel offer with id"); stream.println(); diff --git a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java index 775221b5ed5..32a4564d16d 100644 --- a/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java +++ b/cli/src/main/java/bisq/cli/ColumnHeaderConstants.java @@ -46,6 +46,7 @@ class ColumnHeaderConstants { static final String COL_HEADER_CREATION_DATE = padEnd("Creation Date (UTC)", 20, ' '); static final String COL_HEADER_CURRENCY = "Currency"; static final String COL_HEADER_DIRECTION = "Buy/Sell"; + static final String COL_HEADER_ENABLED = "Enabled"; static final String COL_HEADER_NAME = "Name"; static final String COL_HEADER_PAYMENT_METHOD = "Payment Method"; static final String COL_HEADER_PRICE = "Price in %-3s for 1 BTC"; @@ -64,7 +65,7 @@ class ColumnHeaderConstants { static final String COL_HEADER_TRADE_TX_FEE = padEnd("Tx Fee(BTC)", 12, ' '); static final String COL_HEADER_TRADE_MAKER_FEE = padEnd("Maker Fee(%-3s)", 12, ' '); // "Maker Fee(%-3s)"; static final String COL_HEADER_TRADE_TAKER_FEE = padEnd("Taker Fee(%-3s)", 12, ' '); // "Taker Fee(%-3s)"; - + static final String COL_HEADER_TRIGGER_PRICE = "Trigger Price(%-3s)"; static final String COL_HEADER_TX_ID = "Tx ID"; static final String COL_HEADER_TX_INPUT_SUM = "Tx Inputs (BTC)"; static final String COL_HEADER_TX_OUTPUT_SUM = "Tx Outputs (BTC)"; diff --git a/cli/src/main/java/bisq/cli/CurrencyFormat.java b/cli/src/main/java/bisq/cli/CurrencyFormat.java index 4abf20276ee..8d8a3d11fde 100644 --- a/cli/src/main/java/bisq/cli/CurrencyFormat.java +++ b/cli/src/main/java/bisq/cli/CurrencyFormat.java @@ -22,6 +22,7 @@ import com.google.common.annotations.VisibleForTesting; import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.math.BigDecimal; @@ -35,15 +36,22 @@ @VisibleForTesting public class CurrencyFormat { - private static final NumberFormat NUMBER_FORMAT = NumberFormat.getInstance(Locale.US); + // Use the US locale for all DecimalFormat objects. + private static final DecimalFormatSymbols DECIMAL_FORMAT_SYMBOLS = DecimalFormatSymbols.getInstance(Locale.US); + + // Formats numbers in US locale, human friendly style. + private static final NumberFormat FRIENDLY_NUMBER_FORMAT = NumberFormat.getInstance(Locale.US); + + // Formats numbers for internal use, i.e., grpc request parameters. + private static final DecimalFormat INTERNAL_FIAT_DECIMAL_FORMAT = new DecimalFormat("##############0.0000"); static final BigDecimal SATOSHI_DIVISOR = new BigDecimal(100_000_000); - static final DecimalFormat BTC_FORMAT = new DecimalFormat("###,##0.00000000"); - static final DecimalFormat BTC_TX_FEE_FORMAT = new DecimalFormat("###,###,##0"); + static final DecimalFormat BTC_FORMAT = new DecimalFormat("###,##0.00000000", DECIMAL_FORMAT_SYMBOLS); + static final DecimalFormat BTC_TX_FEE_FORMAT = new DecimalFormat("###,###,##0", DECIMAL_FORMAT_SYMBOLS); static final BigDecimal BSQ_SATOSHI_DIVISOR = new BigDecimal(100); - static final DecimalFormat BSQ_FORMAT = new DecimalFormat("###,###,###,##0.00"); - static final DecimalFormat SEND_BSQ_FORMAT = new DecimalFormat("###########0.00"); + static final DecimalFormat BSQ_FORMAT = new DecimalFormat("###,###,###,##0.00", DECIMAL_FORMAT_SYMBOLS); + static final DecimalFormat SEND_BSQ_FORMAT = new DecimalFormat("###########0.00", DECIMAL_FORMAT_SYMBOLS); static final BigDecimal SECURITY_DEPOSIT_MULTIPLICAND = new BigDecimal("0.01"); @@ -58,10 +66,9 @@ public static String formatBsq(long sats) { } public static String formatBsqAmount(long bsqSats) { - // BSQ sats = trade.getOffer().getVolume() - NUMBER_FORMAT.setMinimumFractionDigits(2); - NUMBER_FORMAT.setMaximumFractionDigits(2); - NUMBER_FORMAT.setRoundingMode(HALF_UP); + FRIENDLY_NUMBER_FORMAT.setMinimumFractionDigits(2); + FRIENDLY_NUMBER_FORMAT.setMaximumFractionDigits(2); + FRIENDLY_NUMBER_FORMAT.setRoundingMode(HALF_UP); return SEND_BSQ_FORMAT.format((double) bsqSats / SATOSHI_DIVISOR.doubleValue()); } @@ -95,38 +102,48 @@ public static String formatCryptoCurrencyVolumeRange(long minVolume, long volume : formatCryptoCurrencyOfferVolume(volume); } - public static String formatMarketPrice(double price) { - NUMBER_FORMAT.setMinimumFractionDigits(4); - NUMBER_FORMAT.setMaximumFractionDigits(4); - return NUMBER_FORMAT.format(price); + public static String formatInternalFiatPrice(BigDecimal price) { + INTERNAL_FIAT_DECIMAL_FORMAT.setMinimumFractionDigits(4); + INTERNAL_FIAT_DECIMAL_FORMAT.setMaximumFractionDigits(4); + return INTERNAL_FIAT_DECIMAL_FORMAT.format(price); + } + + public static String formatInternalFiatPrice(double price) { + FRIENDLY_NUMBER_FORMAT.setMinimumFractionDigits(4); + FRIENDLY_NUMBER_FORMAT.setMaximumFractionDigits(4); + return FRIENDLY_NUMBER_FORMAT.format(price); } public static String formatPrice(long price) { - NUMBER_FORMAT.setMinimumFractionDigits(4); - NUMBER_FORMAT.setMaximumFractionDigits(4); - NUMBER_FORMAT.setRoundingMode(UNNECESSARY); - return NUMBER_FORMAT.format((double) price / 10_000); + FRIENDLY_NUMBER_FORMAT.setMinimumFractionDigits(4); + FRIENDLY_NUMBER_FORMAT.setMaximumFractionDigits(4); + FRIENDLY_NUMBER_FORMAT.setRoundingMode(UNNECESSARY); + return FRIENDLY_NUMBER_FORMAT.format((double) price / 10_000); } public static String formatCryptoCurrencyPrice(long price) { - NUMBER_FORMAT.setMinimumFractionDigits(8); - NUMBER_FORMAT.setMaximumFractionDigits(8); - NUMBER_FORMAT.setRoundingMode(UNNECESSARY); - return NUMBER_FORMAT.format((double) price / SATOSHI_DIVISOR.doubleValue()); + FRIENDLY_NUMBER_FORMAT.setMinimumFractionDigits(8); + FRIENDLY_NUMBER_FORMAT.setMaximumFractionDigits(8); + FRIENDLY_NUMBER_FORMAT.setRoundingMode(UNNECESSARY); + return FRIENDLY_NUMBER_FORMAT.format((double) price / SATOSHI_DIVISOR.doubleValue()); } public static String formatOfferVolume(long volume) { - NUMBER_FORMAT.setMinimumFractionDigits(0); - NUMBER_FORMAT.setMaximumFractionDigits(0); - NUMBER_FORMAT.setRoundingMode(HALF_UP); - return NUMBER_FORMAT.format((double) volume / 10_000); + FRIENDLY_NUMBER_FORMAT.setMinimumFractionDigits(0); + FRIENDLY_NUMBER_FORMAT.setMaximumFractionDigits(0); + FRIENDLY_NUMBER_FORMAT.setRoundingMode(HALF_UP); + return FRIENDLY_NUMBER_FORMAT.format((double) volume / 10_000); } public static String formatCryptoCurrencyOfferVolume(long volume) { - NUMBER_FORMAT.setMinimumFractionDigits(2); - NUMBER_FORMAT.setMaximumFractionDigits(2); - NUMBER_FORMAT.setRoundingMode(HALF_UP); - return NUMBER_FORMAT.format((double) volume / SATOSHI_DIVISOR.doubleValue()); + FRIENDLY_NUMBER_FORMAT.setMinimumFractionDigits(2); + FRIENDLY_NUMBER_FORMAT.setMaximumFractionDigits(2); + FRIENDLY_NUMBER_FORMAT.setRoundingMode(HALF_UP); + return FRIENDLY_NUMBER_FORMAT.format((double) volume / SATOSHI_DIVISOR.doubleValue()); + } + + public static long toInternalFiatPrice(BigDecimal humanFriendlyFiatPrice) { + return humanFriendlyFiatPrice.multiply(new BigDecimal(10_000)).longValue(); } public static long toSatoshis(String btc) { diff --git a/cli/src/main/java/bisq/cli/GrpcClient.java b/cli/src/main/java/bisq/cli/GrpcClient.java index 92784e88298..9b2195bf20d 100644 --- a/cli/src/main/java/bisq/cli/GrpcClient.java +++ b/cli/src/main/java/bisq/cli/GrpcClient.java @@ -21,63 +21,31 @@ import bisq.proto.grpc.BalancesInfo; import bisq.proto.grpc.BsqBalanceInfo; import bisq.proto.grpc.BtcBalanceInfo; -import bisq.proto.grpc.CancelOfferRequest; -import bisq.proto.grpc.ConfirmPaymentReceivedRequest; -import bisq.proto.grpc.ConfirmPaymentStartedRequest; -import bisq.proto.grpc.CreateCryptoCurrencyPaymentAccountRequest; -import bisq.proto.grpc.CreateOfferRequest; -import bisq.proto.grpc.CreatePaymentAccountRequest; -import bisq.proto.grpc.GetAddressBalanceRequest; -import bisq.proto.grpc.GetBalancesRequest; -import bisq.proto.grpc.GetCryptoCurrencyPaymentMethodsRequest; -import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.GetMethodHelpRequest; -import bisq.proto.grpc.GetMyOfferRequest; -import bisq.proto.grpc.GetMyOffersRequest; -import bisq.proto.grpc.GetOfferRequest; -import bisq.proto.grpc.GetOffersRequest; -import bisq.proto.grpc.GetPaymentAccountFormRequest; -import bisq.proto.grpc.GetPaymentAccountsRequest; -import bisq.proto.grpc.GetPaymentMethodsRequest; -import bisq.proto.grpc.GetTradeRequest; -import bisq.proto.grpc.GetTransactionRequest; -import bisq.proto.grpc.GetTxFeeRateRequest; -import bisq.proto.grpc.GetUnusedBsqAddressRequest; import bisq.proto.grpc.GetVersionRequest; -import bisq.proto.grpc.KeepFundsRequest; -import bisq.proto.grpc.LockWalletRequest; -import bisq.proto.grpc.MarketPriceRequest; import bisq.proto.grpc.OfferInfo; import bisq.proto.grpc.RegisterDisputeAgentRequest; -import bisq.proto.grpc.RemoveWalletPasswordRequest; -import bisq.proto.grpc.SendBsqRequest; -import bisq.proto.grpc.SendBtcRequest; -import bisq.proto.grpc.SetTxFeeRatePreferenceRequest; -import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.StopRequest; import bisq.proto.grpc.TakeOfferReply; -import bisq.proto.grpc.TakeOfferRequest; import bisq.proto.grpc.TradeInfo; import bisq.proto.grpc.TxFeeRateInfo; import bisq.proto.grpc.TxInfo; -import bisq.proto.grpc.UnlockWalletRequest; -import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest; -import bisq.proto.grpc.VerifyBsqSentToAddressRequest; -import bisq.proto.grpc.WithdrawFundsRequest; import protobuf.PaymentAccount; import protobuf.PaymentMethod; -import java.util.ArrayList; import java.util.List; import lombok.extern.slf4j.Slf4j; -import static bisq.cli.CryptoCurrencyUtil.isSupportedCryptoCurrency; -import static java.util.Comparator.comparing; -import static java.util.stream.Collectors.toList; -import static protobuf.OfferPayload.Direction.BUY; -import static protobuf.OfferPayload.Direction.SELL; +import static bisq.proto.grpc.EditOfferRequest.EditType; + + + +import bisq.cli.request.OffersServiceRequest; +import bisq.cli.request.PaymentAccountsServiceRequest; +import bisq.cli.request.TradesServiceRequest; +import bisq.cli.request.WalletsServiceRequest; @SuppressWarnings("ResultOfMethodCallIgnored") @@ -85,9 +53,19 @@ public final class GrpcClient { private final GrpcStubs grpcStubs; - - public GrpcClient(String apiHost, int apiPort, String apiPassword) { + private final OffersServiceRequest offersServiceRequest; + private final TradesServiceRequest tradesServiceRequest; + private final WalletsServiceRequest walletsServiceRequest; + private final PaymentAccountsServiceRequest paymentAccountsServiceRequest; + + public GrpcClient(String apiHost, + int apiPort, + String apiPassword) { this.grpcStubs = new GrpcStubs(apiHost, apiPort, apiPassword); + this.offersServiceRequest = new OffersServiceRequest(grpcStubs); + this.tradesServiceRequest = new TradesServiceRequest(grpcStubs); + this.walletsServiceRequest = new WalletsServiceRequest(grpcStubs); + this.paymentAccountsServiceRequest = new PaymentAccountsServiceRequest(grpcStubs); } public String getVersion() { @@ -96,108 +74,67 @@ public String getVersion() { } public BalancesInfo getBalances() { - return getBalances(""); + return walletsServiceRequest.getBalances(); } public BsqBalanceInfo getBsqBalances() { - return getBalances("BSQ").getBsq(); + return walletsServiceRequest.getBsqBalances(); } public BtcBalanceInfo getBtcBalances() { - return getBalances("BTC").getBtc(); + return walletsServiceRequest.getBtcBalances(); } public BalancesInfo getBalances(String currencyCode) { - var request = GetBalancesRequest.newBuilder() - .setCurrencyCode(currencyCode) - .build(); - return grpcStubs.walletsService.getBalances(request).getBalances(); + return walletsServiceRequest.getBalances(currencyCode); } public AddressBalanceInfo getAddressBalance(String address) { - var request = GetAddressBalanceRequest.newBuilder() - .setAddress(address).build(); - return grpcStubs.walletsService.getAddressBalance(request).getAddressBalanceInfo(); + return walletsServiceRequest.getAddressBalance(address); } public double getBtcPrice(String currencyCode) { - var request = MarketPriceRequest.newBuilder() - .setCurrencyCode(currencyCode) - .build(); - return grpcStubs.priceService.getMarketPrice(request).getPrice(); + return walletsServiceRequest.getBtcPrice(currencyCode); } public List getFundingAddresses() { - var request = GetFundingAddressesRequest.newBuilder().build(); - return grpcStubs.walletsService.getFundingAddresses(request).getAddressBalanceInfoList(); + return walletsServiceRequest.getFundingAddresses(); } public String getUnusedBsqAddress() { - var request = GetUnusedBsqAddressRequest.newBuilder().build(); - return grpcStubs.walletsService.getUnusedBsqAddress(request).getAddress(); + return walletsServiceRequest.getUnusedBsqAddress(); } public String getUnusedBtcAddress() { - var request = GetFundingAddressesRequest.newBuilder().build(); - var addressBalances = grpcStubs.walletsService.getFundingAddresses(request) - .getAddressBalanceInfoList(); - //noinspection OptionalGetWithoutIsPresent - return addressBalances.stream() - .filter(AddressBalanceInfo::getIsAddressUnused) - .findFirst() - .get() - .getAddress(); + return walletsServiceRequest.getUnusedBtcAddress(); } public TxInfo sendBsq(String address, String amount, String txFeeRate) { - var request = SendBsqRequest.newBuilder() - .setAddress(address) - .setAmount(amount) - .setTxFeeRate(txFeeRate) - .build(); - return grpcStubs.walletsService.sendBsq(request).getTxInfo(); + return walletsServiceRequest.sendBsq(address, amount, txFeeRate); } public TxInfo sendBtc(String address, String amount, String txFeeRate, String memo) { - var request = SendBtcRequest.newBuilder() - .setAddress(address) - .setAmount(amount) - .setTxFeeRate(txFeeRate) - .setMemo(memo) - .build(); - return grpcStubs.walletsService.sendBtc(request).getTxInfo(); + return walletsServiceRequest.sendBtc(address, amount, txFeeRate, memo); } public boolean verifyBsqSentToAddress(String address, String amount) { - var request = VerifyBsqSentToAddressRequest.newBuilder() - .setAddress(address) - .setAmount(amount) - .build(); - return grpcStubs.walletsService.verifyBsqSentToAddress(request).getIsAmountReceived(); + return walletsServiceRequest.verifyBsqSentToAddress(address, amount); } public TxFeeRateInfo getTxFeeRate() { - var request = GetTxFeeRateRequest.newBuilder().build(); - return grpcStubs.walletsService.getTxFeeRate(request).getTxFeeRateInfo(); + return walletsServiceRequest.getTxFeeRate(); } public TxFeeRateInfo setTxFeeRate(long txFeeRate) { - var request = SetTxFeeRatePreferenceRequest.newBuilder() - .setTxFeeRatePreference(txFeeRate) - .build(); - return grpcStubs.walletsService.setTxFeeRatePreference(request).getTxFeeRateInfo(); + return walletsServiceRequest.setTxFeeRate(txFeeRate); } public TxFeeRateInfo unsetTxFeeRate() { - var request = UnsetTxFeeRatePreferenceRequest.newBuilder().build(); - return grpcStubs.walletsService.unsetTxFeeRatePreference(request).getTxFeeRateInfo(); + return walletsServiceRequest.unsetTxFeeRate(); } public TxInfo getTransaction(String txId) { - var request = GetTransactionRequest.newBuilder() - .setTxId(txId) - .build(); - return grpcStubs.walletsService.getTransaction(request).getTxInfo(); + return walletsServiceRequest.getTransaction(txId); } public OfferInfo createFixedPricedOffer(String direction, @@ -208,7 +145,7 @@ public OfferInfo createFixedPricedOffer(String direction, double securityDeposit, String paymentAcctId, String makerFeeCurrencyCode) { - return createOffer(direction, + return offersServiceRequest.createOffer(direction, currencyCode, amount, minAmount, @@ -217,7 +154,8 @@ public OfferInfo createFixedPricedOffer(String direction, 0.00, securityDeposit, paymentAcctId, - makerFeeCurrencyCode); + makerFeeCurrencyCode, + 0 /* no trigger price */); } public OfferInfo createMarketBasedPricedOffer(String direction, @@ -227,8 +165,9 @@ public OfferInfo createMarketBasedPricedOffer(String direction, double marketPriceMargin, double securityDeposit, String paymentAcctId, - String makerFeeCurrencyCode) { - return createOffer(direction, + String makerFeeCurrencyCode, + long triggerPrice) { + return offersServiceRequest.createOffer(direction, currencyCode, amount, minAmount, @@ -237,7 +176,8 @@ public OfferInfo createMarketBasedPricedOffer(String direction, marketPriceMargin, securityDeposit, paymentAcctId, - makerFeeCurrencyCode); + makerFeeCurrencyCode, + triggerPrice); } public OfferInfo createOffer(String direction, @@ -249,253 +189,192 @@ public OfferInfo createOffer(String direction, double marketPriceMargin, double securityDeposit, String paymentAcctId, - String makerFeeCurrencyCode) { - var request = CreateOfferRequest.newBuilder() - .setDirection(direction) - .setCurrencyCode(currencyCode) - .setAmount(amount) - .setMinAmount(minAmount) - .setUseMarketBasedPrice(useMarketBasedPrice) - .setPrice(fixedPrice) - .setMarketPriceMargin(marketPriceMargin) - .setBuyerSecurityDeposit(securityDeposit) - .setPaymentAccountId(paymentAcctId) - .setMakerFeeCurrencyCode(makerFeeCurrencyCode) - .build(); - return grpcStubs.offersService.createOffer(request).getOffer(); + String makerFeeCurrencyCode, + long triggerPrice) { + return offersServiceRequest.createOffer(direction, + currencyCode, + amount, + minAmount, + useMarketBasedPrice, + fixedPrice, + marketPriceMargin, + securityDeposit, + paymentAcctId, + makerFeeCurrencyCode, + triggerPrice); + } + + public void editOfferActivationState(String offerId, int enable) { + offersServiceRequest.editOfferActivationState(offerId, enable); + } + + public void editOfferFixedPrice(String offerId, String priceAsString) { + offersServiceRequest.editOfferFixedPrice(offerId, priceAsString); + } + + public void editOfferPriceMargin(String offerId, double marketPriceMargin) { + offersServiceRequest.editOfferPriceMargin(offerId, marketPriceMargin); + } + + public void editOfferTriggerPrice(String offerId, long triggerPrice) { + offersServiceRequest.editOfferTriggerPrice(offerId, triggerPrice); + } + + public void editOffer(String offerId, + String priceAsString, + boolean useMarketBasedPrice, + double marketPriceMargin, + long triggerPrice, + int enable, + EditType editType) { + // Take care when using this method directly: + // useMarketBasedPrice = true if margin based offer, false for fixed priced offer + // scaledPriceString fmt = ######.#### + offersServiceRequest.editOffer(offerId, + priceAsString, + useMarketBasedPrice, + marketPriceMargin, + triggerPrice, + enable, + editType); } public void cancelOffer(String offerId) { - var request = CancelOfferRequest.newBuilder() - .setId(offerId) - .build(); - grpcStubs.offersService.cancelOffer(request); + offersServiceRequest.cancelOffer(offerId); } public OfferInfo getOffer(String offerId) { - var request = GetOfferRequest.newBuilder() - .setId(offerId) - .build(); - return grpcStubs.offersService.getOffer(request).getOffer(); + return offersServiceRequest.getOffer(offerId); } public OfferInfo getMyOffer(String offerId) { - var request = GetMyOfferRequest.newBuilder() - .setId(offerId) - .build(); - return grpcStubs.offersService.getMyOffer(request).getOffer(); + return offersServiceRequest.getMyOffer(offerId); } public List getOffers(String direction, String currencyCode) { - if (isSupportedCryptoCurrency(currencyCode)) { - return getCryptoCurrencyOffers(direction, currencyCode); - } else { - var request = GetOffersRequest.newBuilder() - .setDirection(direction) - .setCurrencyCode(currencyCode) - .build(); - return grpcStubs.offersService.getOffers(request).getOffersList(); - } + return offersServiceRequest.getOffers(direction, currencyCode); } public List getCryptoCurrencyOffers(String direction, String currencyCode) { - return getOffers(direction, "BTC").stream() - .filter(o -> o.getBaseCurrencyCode().equalsIgnoreCase(currencyCode)) - .collect(toList()); + return offersServiceRequest.getCryptoCurrencyOffers(direction, currencyCode); } public List getOffersSortedByDate(String currencyCode) { - ArrayList offers = new ArrayList<>(); - offers.addAll(getOffers(BUY.name(), currencyCode)); - offers.addAll(getOffers(SELL.name(), currencyCode)); - return sortOffersByDate(offers); + return offersServiceRequest.getOffersSortedByDate(currencyCode); } public List getOffersSortedByDate(String direction, String currencyCode) { - var offers = getOffers(direction, currencyCode); - return offers.isEmpty() ? offers : sortOffersByDate(offers); + return offersServiceRequest.getOffersSortedByDate(direction, currencyCode); } public List getBsqOffersSortedByDate() { - ArrayList offers = new ArrayList<>(); - offers.addAll(getCryptoCurrencyOffers(BUY.name(), "BSQ")); - offers.addAll(getCryptoCurrencyOffers(SELL.name(), "BSQ")); - return sortOffersByDate(offers); + return offersServiceRequest.getBsqOffersSortedByDate(); } public List getMyOffers(String direction, String currencyCode) { - if (isSupportedCryptoCurrency(currencyCode)) { - return getMyCryptoCurrencyOffers(direction, currencyCode); - } else { - var request = GetMyOffersRequest.newBuilder() - .setDirection(direction) - .setCurrencyCode(currencyCode) - .build(); - return grpcStubs.offersService.getMyOffers(request).getOffersList(); - } + return offersServiceRequest.getMyOffers(direction, currencyCode); } public List getMyCryptoCurrencyOffers(String direction, String currencyCode) { - return getMyOffers(direction, "BTC").stream() - .filter(o -> o.getBaseCurrencyCode().equalsIgnoreCase(currencyCode)) - .collect(toList()); + return offersServiceRequest.getMyCryptoCurrencyOffers(direction, currencyCode); } public List getMyOffersSortedByDate(String direction, String currencyCode) { - var offers = getMyOffers(direction, currencyCode); - return offers.isEmpty() ? offers : sortOffersByDate(offers); + return offersServiceRequest.getMyOffersSortedByDate(direction, currencyCode); } public List getMyOffersSortedByDate(String currencyCode) { - ArrayList offers = new ArrayList<>(); - offers.addAll(getMyOffers(BUY.name(), currencyCode)); - offers.addAll(getMyOffers(SELL.name(), currencyCode)); - return sortOffersByDate(offers); + return offersServiceRequest.getMyOffersSortedByDate(currencyCode); } public List getMyBsqOffersSortedByDate() { - ArrayList offers = new ArrayList<>(); - offers.addAll(getMyCryptoCurrencyOffers(BUY.name(), "BSQ")); - offers.addAll(getMyCryptoCurrencyOffers(SELL.name(), "BSQ")); - return sortOffersByDate(offers); + return offersServiceRequest.getMyBsqOffersSortedByDate(); } public OfferInfo getMostRecentOffer(String direction, String currencyCode) { - List offers = getOffersSortedByDate(direction, currencyCode); - return offers.isEmpty() ? null : offers.get(offers.size() - 1); + return offersServiceRequest.getMostRecentOffer(direction, currencyCode); } public List sortOffersByDate(List offerInfoList) { - return offerInfoList.stream() - .sorted(comparing(OfferInfo::getDate)) - .collect(toList()); + return offersServiceRequest.sortOffersByDate(offerInfoList); } public TakeOfferReply getTakeOfferReply(String offerId, String paymentAccountId, String takerFeeCurrencyCode) { - var request = TakeOfferRequest.newBuilder() - .setOfferId(offerId) - .setPaymentAccountId(paymentAccountId) - .setTakerFeeCurrencyCode(takerFeeCurrencyCode) - .build(); - return grpcStubs.tradesService.takeOffer(request); + return tradesServiceRequest.getTakeOfferReply(offerId, paymentAccountId, takerFeeCurrencyCode); } public TradeInfo takeOffer(String offerId, String paymentAccountId, String takerFeeCurrencyCode) { - var reply = getTakeOfferReply(offerId, paymentAccountId, takerFeeCurrencyCode); - if (reply.hasTrade()) - return reply.getTrade(); - else - throw new IllegalStateException(reply.getFailureReason().getDescription()); + return tradesServiceRequest.takeOffer(offerId, paymentAccountId, takerFeeCurrencyCode); } public TradeInfo getTrade(String tradeId) { - var request = GetTradeRequest.newBuilder() - .setTradeId(tradeId) - .build(); - return grpcStubs.tradesService.getTrade(request).getTrade(); + return tradesServiceRequest.getTrade(tradeId); } public void confirmPaymentStarted(String tradeId) { - var request = ConfirmPaymentStartedRequest.newBuilder() - .setTradeId(tradeId) - .build(); - grpcStubs.tradesService.confirmPaymentStarted(request); + tradesServiceRequest.confirmPaymentStarted(tradeId); } public void confirmPaymentReceived(String tradeId) { - var request = ConfirmPaymentReceivedRequest.newBuilder() - .setTradeId(tradeId) - .build(); - grpcStubs.tradesService.confirmPaymentReceived(request); + tradesServiceRequest.confirmPaymentReceived(tradeId); } public void keepFunds(String tradeId) { - var request = KeepFundsRequest.newBuilder() - .setTradeId(tradeId) - .build(); - grpcStubs.tradesService.keepFunds(request); + tradesServiceRequest.keepFunds(tradeId); } public void withdrawFunds(String tradeId, String address, String memo) { - var request = WithdrawFundsRequest.newBuilder() - .setTradeId(tradeId) - .setAddress(address) - .setMemo(memo) - .build(); - grpcStubs.tradesService.withdrawFunds(request); + tradesServiceRequest.withdrawFunds(tradeId, address, memo); } public List getPaymentMethods() { - var request = GetPaymentMethodsRequest.newBuilder().build(); - return grpcStubs.paymentAccountsService.getPaymentMethods(request).getPaymentMethodsList(); + return paymentAccountsServiceRequest.getPaymentMethods(); } public String getPaymentAcctFormAsJson(String paymentMethodId) { - var request = GetPaymentAccountFormRequest.newBuilder() - .setPaymentMethodId(paymentMethodId) - .build(); - return grpcStubs.paymentAccountsService.getPaymentAccountForm(request).getPaymentAccountFormJson(); + return paymentAccountsServiceRequest.getPaymentAcctFormAsJson(paymentMethodId); } public PaymentAccount createPaymentAccount(String json) { - var request = CreatePaymentAccountRequest.newBuilder() - .setPaymentAccountForm(json) - .build(); - return grpcStubs.paymentAccountsService.createPaymentAccount(request).getPaymentAccount(); + return paymentAccountsServiceRequest.createPaymentAccount(json); } public List getPaymentAccounts() { - var request = GetPaymentAccountsRequest.newBuilder().build(); - return grpcStubs.paymentAccountsService.getPaymentAccounts(request).getPaymentAccountsList(); + return paymentAccountsServiceRequest.getPaymentAccounts(); } public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, String currencyCode, String address, boolean tradeInstant) { - var request = CreateCryptoCurrencyPaymentAccountRequest.newBuilder() - .setAccountName(accountName) - .setCurrencyCode(currencyCode) - .setAddress(address) - .setTradeInstant(tradeInstant) - .build(); - return grpcStubs.paymentAccountsService.createCryptoCurrencyPaymentAccount(request).getPaymentAccount(); + return paymentAccountsServiceRequest.createCryptoCurrencyPaymentAccount(accountName, + currencyCode, + address, + tradeInstant); } public List getCryptoPaymentMethods() { - var request = GetCryptoCurrencyPaymentMethodsRequest.newBuilder().build(); - return grpcStubs.paymentAccountsService.getCryptoCurrencyPaymentMethods(request).getPaymentMethodsList(); + return paymentAccountsServiceRequest.getCryptoPaymentMethods(); } public void lockWallet() { - var request = LockWalletRequest.newBuilder().build(); - grpcStubs.walletsService.lockWallet(request); + walletsServiceRequest.lockWallet(); } public void unlockWallet(String walletPassword, long timeout) { - var request = UnlockWalletRequest.newBuilder() - .setPassword(walletPassword) - .setTimeout(timeout).build(); - grpcStubs.walletsService.unlockWallet(request); + walletsServiceRequest.unlockWallet(walletPassword, timeout); } public void removeWalletPassword(String walletPassword) { - var request = RemoveWalletPasswordRequest.newBuilder() - .setPassword(walletPassword).build(); - grpcStubs.walletsService.removeWalletPassword(request); + walletsServiceRequest.removeWalletPassword(walletPassword); } public void setWalletPassword(String walletPassword) { - var request = SetWalletPasswordRequest.newBuilder() - .setPassword(walletPassword).build(); - grpcStubs.walletsService.setWalletPassword(request); + walletsServiceRequest.setWalletPassword(walletPassword); } public void setWalletPassword(String oldWalletPassword, String newWalletPassword) { - var request = SetWalletPasswordRequest.newBuilder() - .setPassword(oldWalletPassword) - .setNewPassword(newWalletPassword).build(); - grpcStubs.walletsService.setWalletPassword(request); + walletsServiceRequest.setWalletPassword(oldWalletPassword, newWalletPassword); } public void registerDisputeAgent(String disputeAgentType, String registrationKey) { diff --git a/cli/src/main/java/bisq/cli/Method.java b/cli/src/main/java/bisq/cli/Method.java index cf8b1d7df5f..76011877310 100644 --- a/cli/src/main/java/bisq/cli/Method.java +++ b/cli/src/main/java/bisq/cli/Method.java @@ -25,6 +25,7 @@ public enum Method { confirmpaymentreceived, confirmpaymentstarted, createoffer, + editoffer, createpaymentacct, createcryptopaymentacct, getaddressbalance, diff --git a/cli/src/main/java/bisq/cli/TableFormat.java b/cli/src/main/java/bisq/cli/TableFormat.java index 5c123184e94..3d14b5991d4 100644 --- a/cli/src/main/java/bisq/cli/TableFormat.java +++ b/cli/src/main/java/bisq/cli/TableFormat.java @@ -147,58 +147,126 @@ public static String formatPaymentAcctTbl(List paymentAccounts) public static String formatOfferTable(List offers, String currencyCode) { if (offers == null || offers.isEmpty()) - throw new IllegalArgumentException(format("%s offers argument is empty", currencyCode.toLowerCase())); + throw new IllegalArgumentException(format("%s offer list is empty", currencyCode.toLowerCase())); String baseCurrencyCode = offers.get(0).getBaseCurrencyCode(); + boolean isMyOffer = offers.get(0).getIsMyOffer(); return baseCurrencyCode.equalsIgnoreCase("BTC") - ? formatFiatOfferTable(offers, currencyCode) - : formatCryptoCurrencyOfferTable(offers, baseCurrencyCode); + ? formatFiatOfferTable(offers, currencyCode, isMyOffer) + : formatCryptoCurrencyOfferTable(offers, baseCurrencyCode, isMyOffer); } - private static String formatFiatOfferTable(List offers, String fiatCurrencyCode) { + private static String formatFiatOfferTable(List offers, + String fiatCurrencyCode, + boolean isMyOffer) { // Some column values might be longer than header, so we need to calculate them. int amountColWith = getLongestAmountColWidth(offers); int volumeColWidth = getLongestVolumeColWidth(offers); int paymentMethodColWidth = getLongestPaymentMethodColWidth(offers); - String headersFormat = COL_HEADER_DIRECTION + COL_HEADER_DELIMITER - + COL_HEADER_PRICE + COL_HEADER_DELIMITER // includes %s -> fiatCurrencyCode + // "Enabled" and "Trigger Price" columns are displayed for my offers only. + String enabledHeaderFormat = isMyOffer ? + COL_HEADER_ENABLED + COL_HEADER_DELIMITER + : ""; + String triggerPriceHeaderFormat = isMyOffer ? + // COL_HEADER_TRIGGER_PRICE includes %s -> fiatCurrencyCode + COL_HEADER_TRIGGER_PRICE + COL_HEADER_DELIMITER + : ""; + String headersFormat = enabledHeaderFormat + + COL_HEADER_DIRECTION + COL_HEADER_DELIMITER + // COL_HEADER_PRICE includes %s -> fiatCurrencyCode + + COL_HEADER_PRICE + COL_HEADER_DELIMITER + padStart(COL_HEADER_AMOUNT, amountColWith, ' ') + COL_HEADER_DELIMITER // COL_HEADER_VOLUME includes %s -> fiatCurrencyCode + padStart(COL_HEADER_VOLUME, volumeColWidth, ' ') + COL_HEADER_DELIMITER + + triggerPriceHeaderFormat + padEnd(COL_HEADER_PAYMENT_METHOD, paymentMethodColWidth, ' ') + COL_HEADER_DELIMITER + COL_HEADER_CREATION_DATE + COL_HEADER_DELIMITER + COL_HEADER_UUID.trim() + "%n"; String headerLine = format(headersFormat, fiatCurrencyCode.toUpperCase(), - fiatCurrencyCode.toUpperCase()); - String colDataFormat = "%-" + (COL_HEADER_DIRECTION.length() + COL_HEADER_DELIMITER.length()) + "s" - + "%" + (COL_HEADER_PRICE.length() - 1) + "s" - + " %" + amountColWith + "s" - + " %" + (volumeColWidth - 1) + "s" - + " %-" + paymentMethodColWidth + "s" - + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" - + " %-" + COL_HEADER_UUID.length() + "s"; - return headerLine - + offers.stream() - .map(o -> format(colDataFormat, - o.getDirection(), - formatPrice(o.getPrice()), - formatAmountRange(o.getMinAmount(), o.getAmount()), - formatVolumeRange(o.getMinVolume(), o.getVolume()), - o.getPaymentMethodShortName(), - formatTimestamp(o.getDate()), - o.getId())) - .collect(Collectors.joining("\n")); + fiatCurrencyCode.toUpperCase(), + // COL_HEADER_TRIGGER_PRICE includes %s -> fiatCurrencyCode + isMyOffer ? fiatCurrencyCode.toUpperCase() : ""); + String colDataFormat = getFiatOfferColDataFormat(isMyOffer, + amountColWith, + volumeColWidth, + paymentMethodColWidth); + return formattedFiatOfferTable(offers, isMyOffer, headerLine, colDataFormat); + } + + private static String formattedFiatOfferTable(List offers, + boolean isMyOffer, + String headerLine, + String colDataFormat) { + if (isMyOffer) { + return headerLine + + offers.stream() + .map(o -> format(colDataFormat, + o.getIsActivated() ? "YES" : "NO", + o.getDirection(), + formatPrice(o.getPrice()), + formatAmountRange(o.getMinAmount(), o.getAmount()), + formatVolumeRange(o.getMinVolume(), o.getVolume()), + o.getTriggerPrice() == 0 ? "" : formatPrice(o.getTriggerPrice()), + o.getPaymentMethodShortName(), + formatTimestamp(o.getDate()), + o.getId())) + .collect(Collectors.joining("\n")); + } else { + return headerLine + + offers.stream() + .map(o -> format(colDataFormat, + o.getDirection(), + formatPrice(o.getPrice()), + formatAmountRange(o.getMinAmount(), o.getAmount()), + formatVolumeRange(o.getMinVolume(), o.getVolume()), + o.getPaymentMethodShortName(), + formatTimestamp(o.getDate()), + o.getId())) + .collect(Collectors.joining("\n")); + } } - private static String formatCryptoCurrencyOfferTable(List offers, String cryptoCurrencyCode) { + private static String getFiatOfferColDataFormat(boolean isMyOffer, + int amountColWith, + int volumeColWidth, + int paymentMethodColWidth) { + if (isMyOffer) { + return "%-" + (COL_HEADER_ENABLED.length() + COL_HEADER_DELIMITER.length()) + "s" + + "%-" + (COL_HEADER_DIRECTION.length() + COL_HEADER_DELIMITER.length()) + "s" + + "%" + (COL_HEADER_PRICE.length() - 1) + "s" + + " %" + amountColWith + "s" + + " %" + (volumeColWidth - 1) + "s" + + " %" + (COL_HEADER_TRIGGER_PRICE.length() - 1) + "s" + + " %-" + paymentMethodColWidth + "s" + + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" + + " %-" + COL_HEADER_UUID.length() + "s"; + } else { + return "%-" + (COL_HEADER_DIRECTION.length() + COL_HEADER_DELIMITER.length()) + "s" + + "%" + (COL_HEADER_PRICE.length() - 1) + "s" + + " %" + amountColWith + "s" + + " %" + (volumeColWidth - 1) + "s" + + " %-" + paymentMethodColWidth + "s" + + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" + + " %-" + COL_HEADER_UUID.length() + "s"; + } + } + + private static String formatCryptoCurrencyOfferTable(List offers, + String cryptoCurrencyCode, + boolean isMyOffer) { // Some column values might be longer than header, so we need to calculate them. int directionColWidth = getLongestDirectionColWidth(offers); int amountColWith = getLongestAmountColWidth(offers); int volumeColWidth = getLongestCryptoCurrencyVolumeColWidth(offers); int paymentMethodColWidth = getLongestPaymentMethodColWidth(offers); + // "Enabled" column is displayed for my offers only. + String enabledHeaderFormat = isMyOffer ? + COL_HEADER_ENABLED + COL_HEADER_DELIMITER + : ""; // TODO use memoize function to avoid duplicate the formatting done above? - String headersFormat = padEnd(COL_HEADER_DIRECTION, directionColWidth, ' ') + COL_HEADER_DELIMITER + String headersFormat = enabledHeaderFormat + + padEnd(COL_HEADER_DIRECTION, directionColWidth, ' ') + COL_HEADER_DELIMITER + COL_HEADER_PRICE_OF_ALTCOIN + COL_HEADER_DELIMITER // includes %s -> cryptoCurrencyCode + padStart(COL_HEADER_AMOUNT, amountColWith, ' ') + COL_HEADER_DELIMITER // COL_HEADER_VOLUME includes %s -> cryptoCurrencyCode @@ -209,24 +277,51 @@ private static String formatCryptoCurrencyOfferTable(List offers, Str String headerLine = format(headersFormat, cryptoCurrencyCode.toUpperCase(), cryptoCurrencyCode.toUpperCase()); - String colDataFormat = "%-" + directionColWidth + "s" - + "%" + (COL_HEADER_PRICE_OF_ALTCOIN.length() + 1) + "s" - + " %" + amountColWith + "s" - + " %" + (volumeColWidth - 1) + "s" - + " %-" + paymentMethodColWidth + "s" - + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" - + " %-" + COL_HEADER_UUID.length() + "s"; - return headerLine - + offers.stream() - .map(o -> format(colDataFormat, - directionFormat.apply(o), - formatCryptoCurrencyPrice(o.getPrice()), - formatAmountRange(o.getMinAmount(), o.getAmount()), - formatCryptoCurrencyVolumeRange(o.getMinVolume(), o.getVolume()), - o.getPaymentMethodShortName(), - formatTimestamp(o.getDate()), - o.getId())) - .collect(Collectors.joining("\n")); + String colDataFormat; + if (isMyOffer) { + colDataFormat = "%-" + (COL_HEADER_ENABLED.length() + COL_HEADER_DELIMITER.length()) + "s" + + "%-" + directionColWidth + "s" + + "%" + (COL_HEADER_PRICE_OF_ALTCOIN.length() + 1) + "s" + + " %" + amountColWith + "s" + + " %" + (volumeColWidth - 1) + "s" + + " %-" + paymentMethodColWidth + "s" + + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" + + " %-" + COL_HEADER_UUID.length() + "s"; + } else { + colDataFormat = "%-" + directionColWidth + "s" + + "%" + (COL_HEADER_PRICE_OF_ALTCOIN.length() + 1) + "s" + + " %" + amountColWith + "s" + + " %" + (volumeColWidth - 1) + "s" + + " %-" + paymentMethodColWidth + "s" + + " %-" + (COL_HEADER_CREATION_DATE.length()) + "s" + + " %-" + COL_HEADER_UUID.length() + "s"; + } + if (isMyOffer) { + return headerLine + + offers.stream() + .map(o -> format(colDataFormat, + o.getIsActivated() ? "YES" : "NO", + directionFormat.apply(o), + formatCryptoCurrencyPrice(o.getPrice()), + formatAmountRange(o.getMinAmount(), o.getAmount()), + formatCryptoCurrencyVolumeRange(o.getMinVolume(), o.getVolume()), + o.getPaymentMethodShortName(), + formatTimestamp(o.getDate()), + o.getId())) + .collect(Collectors.joining("\n")); + } else { + return headerLine + + offers.stream() + .map(o -> format(colDataFormat, + directionFormat.apply(o), + formatCryptoCurrencyPrice(o.getPrice()), + formatAmountRange(o.getMinAmount(), o.getAmount()), + formatCryptoCurrencyVolumeRange(o.getMinVolume(), o.getVolume()), + o.getPaymentMethodShortName(), + formatTimestamp(o.getDate()), + o.getId())) + .collect(Collectors.joining("\n")); + } } private static int getLongestPaymentMethodColWidth(List offers) { diff --git a/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java b/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java index 25256eb6a99..e0b08ed7713 100644 --- a/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java +++ b/cli/src/main/java/bisq/cli/opts/AbstractMethodOptionParser.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.function.Function; +import java.util.function.Predicate; import lombok.Getter; @@ -64,6 +65,9 @@ public boolean isForHelp() { return options.has(helpOpt); } + protected final Predicate> valueNotSpecified = (opt) -> + !options.hasArgument(opt) || options.valueOf(opt).isEmpty(); + private final Function cliExceptionMessageStyle = (ex) -> { if (ex.getMessage() == null) return null; diff --git a/cli/src/main/java/bisq/cli/opts/EditOfferOptionParser.java b/cli/src/main/java/bisq/cli/opts/EditOfferOptionParser.java new file mode 100644 index 00000000000..288f1a9f40d --- /dev/null +++ b/cli/src/main/java/bisq/cli/opts/EditOfferOptionParser.java @@ -0,0 +1,281 @@ +/* + * 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.cli.opts; + + +import bisq.proto.grpc.EditOfferRequest; + +import joptsimple.OptionSpec; + +import java.math.BigDecimal; + +import static bisq.cli.opts.OptLabel.*; +import static bisq.proto.grpc.EditOfferRequest.EditType.*; +import static java.lang.String.format; + + + +import org.checkerframework.checker.nullness.qual.Nullable; + +public class EditOfferOptionParser extends AbstractMethodOptionParser implements MethodOpts { + + static int OPT_ENABLE_ON = 1; + static int OPT_ENABLE_OFF = 0; + static int OPT_ENABLE_IGNORED = -1; + + final OptionSpec offerIdOpt = parser.accepts(OPT_OFFER_ID, "id of offer to cancel") + .withRequiredArg(); + + final OptionSpec fixedPriceOpt = parser.accepts(OPT_FIXED_PRICE, "fixed btc price") + .withOptionalArg() + .defaultsTo("0"); + + final OptionSpec mktPriceMarginOpt = parser.accepts(OPT_MKT_PRICE_MARGIN, + "market btc price margin (%)") + .withOptionalArg() + .defaultsTo("0.00"); + + final OptionSpec triggerPriceOpt = parser.accepts(OPT_TRIGGER_PRICE, + "trigger price (applies to mkt price margin based offers)") + .withOptionalArg() + .defaultsTo("0"); + + // The 'enable' string opt is optional, and can be empty (meaning do not change + // activation state). For this reason, a boolean type is not used (can only be + // true or false). + final OptionSpec enableOpt = parser.accepts(OPT_ENABLE, + "enable or disable offer") + .withOptionalArg() + .ofType(String.class); + + private EditOfferRequest.EditType offerEditType; + + public EditOfferOptionParser(String[] args) { + super(args); + } + + public EditOfferOptionParser parse() { + super.parse(); + + // Short circuit opt validation if user just wants help. + if (options.has(helpOpt)) + return this; + + if (!options.has(offerIdOpt) || options.valueOf(offerIdOpt).isEmpty()) + throw new IllegalArgumentException("no offer id specified"); + + boolean hasNoEditDetails = !options.has(fixedPriceOpt) + && !options.has(mktPriceMarginOpt) + && !options.has(triggerPriceOpt) + && !options.has(enableOpt); + if (hasNoEditDetails) + throw new IllegalArgumentException("no edit details specified"); + + if (options.has(enableOpt)) { + if (valueNotSpecified.test(enableOpt)) + throw new IllegalArgumentException("invalid enable value specified, must be true|false"); + + var enableOptValue = options.valueOf(enableOpt); + if (!enableOptValue.equalsIgnoreCase("true") + && !enableOptValue.equalsIgnoreCase("false")) + throw new IllegalArgumentException("invalid enable value specified, must be true|false"); + + // A single enable opt is a valid opt combo. + boolean enableOptIsOnlyOpt = !options.has(fixedPriceOpt) + && !options.has(mktPriceMarginOpt) + && !options.has(triggerPriceOpt); + if (enableOptIsOnlyOpt) { + offerEditType = ACTIVATION_STATE_ONLY; + return this; + } + } + + if (options.has(fixedPriceOpt)) { + if (valueNotSpecified.test(fixedPriceOpt)) + throw new IllegalArgumentException("no fixed price specified"); + + String fixedPriceAsString = options.valueOf(fixedPriceOpt); + verifyStringIsValidDouble(fixedPriceAsString); + + boolean fixedPriceOptIsOnlyOpt = !options.has(mktPriceMarginOpt) + && !options.has(triggerPriceOpt) + && !options.has(enableOpt); + if (fixedPriceOptIsOnlyOpt) { + offerEditType = FIXED_PRICE_ONLY; + return this; + } + + boolean fixedPriceOptAndEnableOptAreOnlyOpts = options.has(enableOpt) + && !options.has(mktPriceMarginOpt) + && !options.has(triggerPriceOpt); + if (fixedPriceOptAndEnableOptAreOnlyOpts) { + offerEditType = FIXED_PRICE_AND_ACTIVATION_STATE; + return this; + } + } + + if (options.has(mktPriceMarginOpt)) { + if (valueNotSpecified.test(mktPriceMarginOpt)) + throw new IllegalArgumentException("no mkt price margin specified"); + + String priceMarginAsString = options.valueOf(mktPriceMarginOpt); + if (priceMarginAsString.isEmpty()) + throw new IllegalArgumentException("no market price margin specified"); + + verifyStringIsValidDouble(priceMarginAsString); + + boolean mktPriceMarginOptIsOnlyOpt = !options.has(triggerPriceOpt) + && !options.has(fixedPriceOpt) + && !options.has(enableOpt); + if (mktPriceMarginOptIsOnlyOpt) { + offerEditType = MKT_PRICE_MARGIN_ONLY; + return this; + } + + boolean mktPriceMarginOptAndEnableOptAreOnlyOpts = options.has(enableOpt) + && !options.has(triggerPriceOpt); + if (mktPriceMarginOptAndEnableOptAreOnlyOpts) { + offerEditType = MKT_PRICE_MARGIN_AND_ACTIVATION_STATE; + return this; + } + } + + if (options.has(triggerPriceOpt)) { + if (valueNotSpecified.test(triggerPriceOpt)) + throw new IllegalArgumentException("no trigger price specified"); + + String triggerPriceAsString = options.valueOf(fixedPriceOpt); + if (triggerPriceAsString.isEmpty()) + throw new IllegalArgumentException("trigger price not specified"); + + verifyStringIsValidDouble(triggerPriceAsString); + + boolean triggerPriceOptIsOnlyOpt = !options.has(mktPriceMarginOpt) + && !options.has(fixedPriceOpt) + && !options.has(enableOpt); + if (triggerPriceOptIsOnlyOpt) { + offerEditType = TRIGGER_PRICE_ONLY; + return this; + } + + boolean triggerPriceOptAndEnableOptAreOnlyOpts = !options.has(mktPriceMarginOpt) + && !options.has(fixedPriceOpt) + && options.has(enableOpt); + if (triggerPriceOptAndEnableOptAreOnlyOpts) { + offerEditType = TRIGGER_PRICE_AND_ACTIVATION_STATE; + return this; + } + } + + if (options.has(mktPriceMarginOpt) && options.has(fixedPriceOpt)) + throw new IllegalArgumentException("cannot specify market price margin and fixed price"); + + if (options.has(fixedPriceOpt) && options.has(triggerPriceOpt)) + throw new IllegalArgumentException("trigger price cannot be set on fixed price offers"); + + if (options.has(mktPriceMarginOpt) && options.has(triggerPriceOpt) && !options.has(enableOpt)) { + offerEditType = MKT_PRICE_MARGIN_AND_TRIGGER_PRICE; + return this; + } + + if (options.has(mktPriceMarginOpt) && options.has(triggerPriceOpt) && options.has(enableOpt)) { + offerEditType = MKT_PRICE_MARGIN_AND_TRIGGER_PRICE_AND_ACTIVATION_STATE; + return this; + } + + return this; + } + + public String getOfferId() { + return options.valueOf(offerIdOpt); + } + + public String getFixedPrice() { + if (offerEditType.equals(FIXED_PRICE_ONLY) || offerEditType.equals(FIXED_PRICE_AND_ACTIVATION_STATE)) { + return options.has(fixedPriceOpt) ? options.valueOf(fixedPriceOpt) : "0"; + } else { + return "0"; + } + } + + public String getTriggerPrice() { + if (offerEditType.equals(TRIGGER_PRICE_ONLY) + || offerEditType.equals(TRIGGER_PRICE_AND_ACTIVATION_STATE) + || offerEditType.equals(MKT_PRICE_MARGIN_AND_TRIGGER_PRICE) + || offerEditType.equals(MKT_PRICE_MARGIN_AND_TRIGGER_PRICE_AND_ACTIVATION_STATE)) { + return options.has(triggerPriceOpt) ? options.valueOf(triggerPriceOpt) : "0"; + } else { + return "0"; + } + } + + public BigDecimal getTriggerPriceAsBigDecimal() { + return new BigDecimal(getTriggerPrice()); + } + + public String getMktPriceMargin() { + if (offerEditType.equals(MKT_PRICE_MARGIN_ONLY) + || offerEditType.equals(MKT_PRICE_MARGIN_AND_ACTIVATION_STATE) + || offerEditType.equals(MKT_PRICE_MARGIN_AND_TRIGGER_PRICE) + || offerEditType.equals(MKT_PRICE_MARGIN_AND_TRIGGER_PRICE_AND_ACTIVATION_STATE)) { + return isUsingMktPriceMargin() ? options.valueOf(mktPriceMarginOpt) : "0.00"; + } else { + return "0.00"; + } + } + + public BigDecimal getMktPriceMarginAsBigDecimal() { + return new BigDecimal(options.valueOf(mktPriceMarginOpt)); + } + + public boolean isUsingMktPriceMargin() { + return !offerEditType.equals(FIXED_PRICE_ONLY) + && !offerEditType.equals(FIXED_PRICE_AND_ACTIVATION_STATE); + } + + public int getEnableAsSignedInt() { + // Client sends sint32 in grpc request, not a bool that can only be true or false. + // If enable = -1, do not change activation state + // If enable = 0, set state = AVAILABLE + // If enable = 1, set state = DEACTIVATED + @Nullable + Boolean input = isEnable(); + return input == null + ? OPT_ENABLE_IGNORED + : input ? OPT_ENABLE_ON : OPT_ENABLE_OFF; + } + + @Nullable + public Boolean isEnable() { + return options.has(enableOpt) + ? Boolean.valueOf(options.valueOf(enableOpt)) + : null; + } + + public EditOfferRequest.EditType getOfferEditType() { + return offerEditType; + } + + private void verifyStringIsValidDouble(String string) { + try { + Double.valueOf(string); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(format("%s is not a number", string)); + } + } +} diff --git a/cli/src/main/java/bisq/cli/opts/OptLabel.java b/cli/src/main/java/bisq/cli/opts/OptLabel.java index 084c230aae3..70dda3e6fc3 100644 --- a/cli/src/main/java/bisq/cli/opts/OptLabel.java +++ b/cli/src/main/java/bisq/cli/opts/OptLabel.java @@ -27,6 +27,7 @@ public class OptLabel { public final static String OPT_CURRENCY_CODE = "currency-code"; public final static String OPT_DIRECTION = "direction"; public final static String OPT_DISPUTE_AGENT_TYPE = "dispute-agent-type"; + public final static String OPT_ENABLE = "enable"; public final static String OPT_FEE_CURRENCY = "fee-currency"; public final static String OPT_FIXED_PRICE = "fixed-price"; public final static String OPT_HELP = "help"; @@ -47,6 +48,7 @@ public class OptLabel { public final static String OPT_TRADE_INSTANT = "trade-instant"; public final static String OPT_TIMEOUT = "timeout"; public final static String OPT_TRANSACTION_ID = "transaction-id"; + public final static String OPT_TRIGGER_PRICE = "trigger-price"; public final static String OPT_TX_FEE_RATE = "tx-fee-rate"; public final static String OPT_WALLET_PASSWORD = "wallet-password"; public final static String OPT_NEW_WALLET_PASSWORD = "new-wallet-password"; diff --git a/cli/src/main/java/bisq/cli/request/OffersServiceRequest.java b/cli/src/main/java/bisq/cli/request/OffersServiceRequest.java new file mode 100644 index 00000000000..215c4f3e80d --- /dev/null +++ b/cli/src/main/java/bisq/cli/request/OffersServiceRequest.java @@ -0,0 +1,319 @@ +/* + * 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.cli.request; + +import bisq.proto.grpc.CancelOfferRequest; +import bisq.proto.grpc.CreateOfferRequest; +import bisq.proto.grpc.EditOfferRequest; +import bisq.proto.grpc.GetMyOfferRequest; +import bisq.proto.grpc.GetMyOffersRequest; +import bisq.proto.grpc.GetOfferRequest; +import bisq.proto.grpc.GetOffersRequest; +import bisq.proto.grpc.OfferInfo; + +import java.math.BigDecimal; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import static bisq.proto.grpc.EditOfferRequest.EditType.ACTIVATION_STATE_ONLY; +import static bisq.proto.grpc.EditOfferRequest.EditType.FIXED_PRICE_ONLY; +import static bisq.proto.grpc.EditOfferRequest.EditType.MKT_PRICE_MARGIN_ONLY; +import static bisq.proto.grpc.EditOfferRequest.EditType.TRIGGER_PRICE_ONLY; +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toList; +import static protobuf.OfferPayload.Direction.BUY; +import static protobuf.OfferPayload.Direction.SELL; + + + +import bisq.cli.GrpcStubs; + +public class OffersServiceRequest { + + private final Function scaledPriceStringRequestFormat = (price) -> { + BigDecimal factor = new BigDecimal(10).pow(4); + //noinspection BigDecimalMethodWithoutRoundingCalled + return new BigDecimal(price).divide(factor).toPlainString(); + }; + + private final GrpcStubs grpcStubs; + + public OffersServiceRequest(GrpcStubs grpcStubs) { + this.grpcStubs = grpcStubs; + } + + public OfferInfo createFixedPricedOffer(String direction, + String currencyCode, + long amount, + long minAmount, + String fixedPrice, + double securityDeposit, + String paymentAcctId, + String makerFeeCurrencyCode) { + return createOffer(direction, + currencyCode, + amount, + minAmount, + false, + fixedPrice, + 0.00, + securityDeposit, + paymentAcctId, + makerFeeCurrencyCode, + 0 /* no trigger price */); + } + + public OfferInfo createMarketBasedPricedOffer(String direction, + String currencyCode, + long amount, + long minAmount, + double marketPriceMargin, + double securityDeposit, + String paymentAcctId, + String makerFeeCurrencyCode, + long triggerPrice) { + return createOffer(direction, + currencyCode, + amount, + minAmount, + true, + "0", + marketPriceMargin, + securityDeposit, + paymentAcctId, + makerFeeCurrencyCode, + triggerPrice); + } + + public OfferInfo createOffer(String direction, + String currencyCode, + long amount, + long minAmount, + boolean useMarketBasedPrice, + String fixedPrice, + double marketPriceMargin, + double securityDeposit, + String paymentAcctId, + String makerFeeCurrencyCode, + long triggerPrice) { + var request = CreateOfferRequest.newBuilder() + .setDirection(direction) + .setCurrencyCode(currencyCode) + .setAmount(amount) + .setMinAmount(minAmount) + .setUseMarketBasedPrice(useMarketBasedPrice) + .setPrice(fixedPrice) + .setMarketPriceMargin(marketPriceMargin) + .setBuyerSecurityDeposit(securityDeposit) + .setPaymentAccountId(paymentAcctId) + .setMakerFeeCurrencyCode(makerFeeCurrencyCode) + .setTriggerPrice(triggerPrice) + .build(); + return grpcStubs.offersService.createOffer(request).getOffer(); + } + + public void editOfferActivationState(String offerId, int enable) { + var offer = getMyOffer(offerId); + var scaledPriceString = offer.getUseMarketBasedPrice() + ? "0.00" + : scaledPriceStringRequestFormat.apply(offer.getPrice()); + editOffer(offerId, + scaledPriceString, + offer.getUseMarketBasedPrice(), + offer.getMarketPriceMargin(), + offer.getTriggerPrice(), + enable, + ACTIVATION_STATE_ONLY); + } + + public void editOfferFixedPrice(String offerId, String rawPriceString) { + var offer = getMyOffer(offerId); + editOffer(offerId, + rawPriceString, + false, + offer.getMarketPriceMargin(), + offer.getTriggerPrice(), + offer.getIsActivated() ? 1 : 0, + FIXED_PRICE_ONLY); + } + + public void editOfferPriceMargin(String offerId, double marketPriceMargin) { + var offer = getMyOffer(offerId); + editOffer(offerId, + "0.00", + true, + marketPriceMargin, + offer.getTriggerPrice(), + offer.getIsActivated() ? 1 : 0, + MKT_PRICE_MARGIN_ONLY); + } + + public void editOfferTriggerPrice(String offerId, long triggerPrice) { + var offer = getMyOffer(offerId); + editOffer(offerId, + "0.00", + offer.getUseMarketBasedPrice(), + offer.getMarketPriceMargin(), + triggerPrice, + offer.getIsActivated() ? 1 : 0, + TRIGGER_PRICE_ONLY); + } + + public void editOffer(String offerId, + String scaledPriceString, + boolean useMarketBasedPrice, + double marketPriceMargin, + long triggerPrice, + int enable, + EditOfferRequest.EditType editType) { + // Take care when using this method directly: + // useMarketBasedPrice = true if margin based offer, false for fixed priced offer + // scaledPriceString fmt = ######.#### + var request = EditOfferRequest.newBuilder() + .setId(offerId) + .setPrice(scaledPriceString) + .setUseMarketBasedPrice(useMarketBasedPrice) + .setMarketPriceMargin(marketPriceMargin) + .setTriggerPrice(triggerPrice) + .setEnable(enable) + .setEditType(editType) + .build(); + //noinspection ResultOfMethodCallIgnored + grpcStubs.offersService.editOffer(request); + } + + public void cancelOffer(String offerId) { + var request = CancelOfferRequest.newBuilder() + .setId(offerId) + .build(); + //noinspection ResultOfMethodCallIgnored + grpcStubs.offersService.cancelOffer(request); + } + + public OfferInfo getOffer(String offerId) { + var request = GetOfferRequest.newBuilder() + .setId(offerId) + .build(); + return grpcStubs.offersService.getOffer(request).getOffer(); + } + + public OfferInfo getMyOffer(String offerId) { + var request = GetMyOfferRequest.newBuilder() + .setId(offerId) + .build(); + return grpcStubs.offersService.getMyOffer(request).getOffer(); + } + + public List getOffers(String direction, String currencyCode) { + if (isSupportedCryptoCurrency(currencyCode)) { + return getCryptoCurrencyOffers(direction, currencyCode); + } else { + var request = GetOffersRequest.newBuilder() + .setDirection(direction) + .setCurrencyCode(currencyCode) + .build(); + return grpcStubs.offersService.getOffers(request).getOffersList(); + } + } + + public List getCryptoCurrencyOffers(String direction, String currencyCode) { + return getOffers(direction, "BTC").stream() + .filter(o -> o.getBaseCurrencyCode().equalsIgnoreCase(currencyCode)) + .collect(toList()); + } + + public List getOffersSortedByDate(String currencyCode) { + ArrayList offers = new ArrayList<>(); + offers.addAll(getOffers(BUY.name(), currencyCode)); + offers.addAll(getOffers(SELL.name(), currencyCode)); + return sortOffersByDate(offers); + } + + public List getOffersSortedByDate(String direction, String currencyCode) { + var offers = getOffers(direction, currencyCode); + return offers.isEmpty() ? offers : sortOffersByDate(offers); + } + + public List getBsqOffersSortedByDate() { + ArrayList offers = new ArrayList<>(); + offers.addAll(getCryptoCurrencyOffers(BUY.name(), "BSQ")); + offers.addAll(getCryptoCurrencyOffers(SELL.name(), "BSQ")); + return sortOffersByDate(offers); + } + + public List getMyOffers(String direction, String currencyCode) { + if (isSupportedCryptoCurrency(currencyCode)) { + return getMyCryptoCurrencyOffers(direction, currencyCode); + } else { + var request = GetMyOffersRequest.newBuilder() + .setDirection(direction) + .setCurrencyCode(currencyCode) + .build(); + return grpcStubs.offersService.getMyOffers(request).getOffersList(); + } + } + + public List getMyCryptoCurrencyOffers(String direction, String currencyCode) { + return getMyOffers(direction, "BTC").stream() + .filter(o -> o.getBaseCurrencyCode().equalsIgnoreCase(currencyCode)) + .collect(toList()); + } + + public List getMyOffersSortedByDate(String direction, String currencyCode) { + var offers = getMyOffers(direction, currencyCode); + return offers.isEmpty() ? offers : sortOffersByDate(offers); + } + + public List getMyOffersSortedByDate(String currencyCode) { + ArrayList offers = new ArrayList<>(); + offers.addAll(getMyOffers(BUY.name(), currencyCode)); + offers.addAll(getMyOffers(SELL.name(), currencyCode)); + return sortOffersByDate(offers); + } + + public List getMyBsqOffersSortedByDate() { + ArrayList offers = new ArrayList<>(); + offers.addAll(getMyCryptoCurrencyOffers(BUY.name(), "BSQ")); + offers.addAll(getMyCryptoCurrencyOffers(SELL.name(), "BSQ")); + return sortOffersByDate(offers); + } + + public OfferInfo getMostRecentOffer(String direction, String currencyCode) { + List offers = getOffersSortedByDate(direction, currencyCode); + return offers.isEmpty() ? null : offers.get(offers.size() - 1); + } + + public List sortOffersByDate(List offerInfoList) { + return offerInfoList.stream() + .sorted(comparing(OfferInfo::getDate)) + .collect(toList()); + } + + private static boolean isSupportedCryptoCurrency(String currencyCode) { + return getSupportedCryptoCurrencies().contains(currencyCode.toUpperCase()); + } + + private static List getSupportedCryptoCurrencies() { + final List result = new ArrayList<>(); + result.add("BSQ"); + result.sort(String::compareTo); + return result; + } +} diff --git a/cli/src/main/java/bisq/cli/request/PaymentAccountsServiceRequest.java b/cli/src/main/java/bisq/cli/request/PaymentAccountsServiceRequest.java new file mode 100644 index 00000000000..467aa51462e --- /dev/null +++ b/cli/src/main/java/bisq/cli/request/PaymentAccountsServiceRequest.java @@ -0,0 +1,85 @@ +/* + * 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.cli.request; + +import bisq.proto.grpc.CreateCryptoCurrencyPaymentAccountRequest; +import bisq.proto.grpc.CreatePaymentAccountRequest; +import bisq.proto.grpc.GetCryptoCurrencyPaymentMethodsRequest; +import bisq.proto.grpc.GetPaymentAccountFormRequest; +import bisq.proto.grpc.GetPaymentAccountsRequest; +import bisq.proto.grpc.GetPaymentMethodsRequest; + +import protobuf.PaymentAccount; +import protobuf.PaymentMethod; + +import java.util.List; + + + +import bisq.cli.GrpcStubs; + +public class PaymentAccountsServiceRequest { + + private final GrpcStubs grpcStubs; + + public PaymentAccountsServiceRequest(GrpcStubs grpcStubs) { + this.grpcStubs = grpcStubs; + } + + public List getPaymentMethods() { + var request = GetPaymentMethodsRequest.newBuilder().build(); + return grpcStubs.paymentAccountsService.getPaymentMethods(request).getPaymentMethodsList(); + } + + public String getPaymentAcctFormAsJson(String paymentMethodId) { + var request = GetPaymentAccountFormRequest.newBuilder() + .setPaymentMethodId(paymentMethodId) + .build(); + return grpcStubs.paymentAccountsService.getPaymentAccountForm(request).getPaymentAccountFormJson(); + } + + public PaymentAccount createPaymentAccount(String json) { + var request = CreatePaymentAccountRequest.newBuilder() + .setPaymentAccountForm(json) + .build(); + return grpcStubs.paymentAccountsService.createPaymentAccount(request).getPaymentAccount(); + } + + public List getPaymentAccounts() { + var request = GetPaymentAccountsRequest.newBuilder().build(); + return grpcStubs.paymentAccountsService.getPaymentAccounts(request).getPaymentAccountsList(); + } + + public PaymentAccount createCryptoCurrencyPaymentAccount(String accountName, + String currencyCode, + String address, + boolean tradeInstant) { + var request = CreateCryptoCurrencyPaymentAccountRequest.newBuilder() + .setAccountName(accountName) + .setCurrencyCode(currencyCode) + .setAddress(address) + .setTradeInstant(tradeInstant) + .build(); + return grpcStubs.paymentAccountsService.createCryptoCurrencyPaymentAccount(request).getPaymentAccount(); + } + + public List getCryptoPaymentMethods() { + var request = GetCryptoCurrencyPaymentMethodsRequest.newBuilder().build(); + return grpcStubs.paymentAccountsService.getCryptoCurrencyPaymentMethods(request).getPaymentMethodsList(); + } +} diff --git a/cli/src/main/java/bisq/cli/request/TradesServiceRequest.java b/cli/src/main/java/bisq/cli/request/TradesServiceRequest.java new file mode 100644 index 00000000000..6d57bb03547 --- /dev/null +++ b/cli/src/main/java/bisq/cli/request/TradesServiceRequest.java @@ -0,0 +1,94 @@ +/* + * 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.cli.request; + +import bisq.proto.grpc.ConfirmPaymentReceivedRequest; +import bisq.proto.grpc.ConfirmPaymentStartedRequest; +import bisq.proto.grpc.GetTradeRequest; +import bisq.proto.grpc.KeepFundsRequest; +import bisq.proto.grpc.TakeOfferReply; +import bisq.proto.grpc.TakeOfferRequest; +import bisq.proto.grpc.TradeInfo; +import bisq.proto.grpc.WithdrawFundsRequest; + + + +import bisq.cli.GrpcStubs; + +public class TradesServiceRequest { + + private final GrpcStubs grpcStubs; + + public TradesServiceRequest(GrpcStubs grpcStubs) { + this.grpcStubs = grpcStubs; + } + + public TakeOfferReply getTakeOfferReply(String offerId, String paymentAccountId, String takerFeeCurrencyCode) { + var request = TakeOfferRequest.newBuilder() + .setOfferId(offerId) + .setPaymentAccountId(paymentAccountId) + .setTakerFeeCurrencyCode(takerFeeCurrencyCode) + .build(); + return grpcStubs.tradesService.takeOffer(request); + } + + public TradeInfo takeOffer(String offerId, String paymentAccountId, String takerFeeCurrencyCode) { + var reply = getTakeOfferReply(offerId, paymentAccountId, takerFeeCurrencyCode); + if (reply.hasTrade()) + return reply.getTrade(); + else + throw new IllegalStateException(reply.getFailureReason().getDescription()); + } + + public TradeInfo getTrade(String tradeId) { + var request = GetTradeRequest.newBuilder() + .setTradeId(tradeId) + .build(); + return grpcStubs.tradesService.getTrade(request).getTrade(); + } + + public void confirmPaymentStarted(String tradeId) { + var request = ConfirmPaymentStartedRequest.newBuilder() + .setTradeId(tradeId) + .build(); + grpcStubs.tradesService.confirmPaymentStarted(request); + } + + public void confirmPaymentReceived(String tradeId) { + var request = ConfirmPaymentReceivedRequest.newBuilder() + .setTradeId(tradeId) + .build(); + grpcStubs.tradesService.confirmPaymentReceived(request); + } + + public void keepFunds(String tradeId) { + var request = KeepFundsRequest.newBuilder() + .setTradeId(tradeId) + .build(); + grpcStubs.tradesService.keepFunds(request); + } + + public void withdrawFunds(String tradeId, String address, String memo) { + var request = WithdrawFundsRequest.newBuilder() + .setTradeId(tradeId) + .setAddress(address) + .setMemo(memo) + .build(); + grpcStubs.tradesService.withdrawFunds(request); + } +} diff --git a/cli/src/main/java/bisq/cli/request/WalletsServiceRequest.java b/cli/src/main/java/bisq/cli/request/WalletsServiceRequest.java new file mode 100644 index 00000000000..e4e7f07c5f5 --- /dev/null +++ b/cli/src/main/java/bisq/cli/request/WalletsServiceRequest.java @@ -0,0 +1,192 @@ +/* + * 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.cli.request; + +import bisq.proto.grpc.AddressBalanceInfo; +import bisq.proto.grpc.BalancesInfo; +import bisq.proto.grpc.BsqBalanceInfo; +import bisq.proto.grpc.BtcBalanceInfo; +import bisq.proto.grpc.GetAddressBalanceRequest; +import bisq.proto.grpc.GetBalancesRequest; +import bisq.proto.grpc.GetFundingAddressesRequest; +import bisq.proto.grpc.GetTransactionRequest; +import bisq.proto.grpc.GetTxFeeRateRequest; +import bisq.proto.grpc.GetUnusedBsqAddressRequest; +import bisq.proto.grpc.LockWalletRequest; +import bisq.proto.grpc.MarketPriceRequest; +import bisq.proto.grpc.RemoveWalletPasswordRequest; +import bisq.proto.grpc.SendBsqRequest; +import bisq.proto.grpc.SendBtcRequest; +import bisq.proto.grpc.SetTxFeeRatePreferenceRequest; +import bisq.proto.grpc.SetWalletPasswordRequest; +import bisq.proto.grpc.TxFeeRateInfo; +import bisq.proto.grpc.TxInfo; +import bisq.proto.grpc.UnlockWalletRequest; +import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest; +import bisq.proto.grpc.VerifyBsqSentToAddressRequest; + +import java.util.List; + + + +import bisq.cli.GrpcStubs; + +public class WalletsServiceRequest { + + private final GrpcStubs grpcStubs; + + public WalletsServiceRequest(GrpcStubs grpcStubs) { + this.grpcStubs = grpcStubs; + } + + public BalancesInfo getBalances() { + return getBalances(""); + } + + public BsqBalanceInfo getBsqBalances() { + return getBalances("BSQ").getBsq(); + } + + public BtcBalanceInfo getBtcBalances() { + return getBalances("BTC").getBtc(); + } + + public BalancesInfo getBalances(String currencyCode) { + var request = GetBalancesRequest.newBuilder() + .setCurrencyCode(currencyCode) + .build(); + return grpcStubs.walletsService.getBalances(request).getBalances(); + } + + public AddressBalanceInfo getAddressBalance(String address) { + var request = GetAddressBalanceRequest.newBuilder() + .setAddress(address).build(); + return grpcStubs.walletsService.getAddressBalance(request).getAddressBalanceInfo(); + } + + public double getBtcPrice(String currencyCode) { + var request = MarketPriceRequest.newBuilder() + .setCurrencyCode(currencyCode) + .build(); + return grpcStubs.priceService.getMarketPrice(request).getPrice(); + } + + public List getFundingAddresses() { + var request = GetFundingAddressesRequest.newBuilder().build(); + return grpcStubs.walletsService.getFundingAddresses(request).getAddressBalanceInfoList(); + } + + public String getUnusedBsqAddress() { + var request = GetUnusedBsqAddressRequest.newBuilder().build(); + return grpcStubs.walletsService.getUnusedBsqAddress(request).getAddress(); + } + + public String getUnusedBtcAddress() { + var request = GetFundingAddressesRequest.newBuilder().build(); + var addressBalances = grpcStubs.walletsService.getFundingAddresses(request) + .getAddressBalanceInfoList(); + //noinspection OptionalGetWithoutIsPresent + return addressBalances.stream() + .filter(AddressBalanceInfo::getIsAddressUnused) + .findFirst() + .get() + .getAddress(); + } + + public TxInfo sendBsq(String address, String amount, String txFeeRate) { + var request = SendBsqRequest.newBuilder() + .setAddress(address) + .setAmount(amount) + .setTxFeeRate(txFeeRate) + .build(); + return grpcStubs.walletsService.sendBsq(request).getTxInfo(); + } + + public TxInfo sendBtc(String address, String amount, String txFeeRate, String memo) { + var request = SendBtcRequest.newBuilder() + .setAddress(address) + .setAmount(amount) + .setTxFeeRate(txFeeRate) + .setMemo(memo) + .build(); + return grpcStubs.walletsService.sendBtc(request).getTxInfo(); + } + + public boolean verifyBsqSentToAddress(String address, String amount) { + var request = VerifyBsqSentToAddressRequest.newBuilder() + .setAddress(address) + .setAmount(amount) + .build(); + return grpcStubs.walletsService.verifyBsqSentToAddress(request).getIsAmountReceived(); + } + + public TxFeeRateInfo getTxFeeRate() { + var request = GetTxFeeRateRequest.newBuilder().build(); + return grpcStubs.walletsService.getTxFeeRate(request).getTxFeeRateInfo(); + } + + public TxFeeRateInfo setTxFeeRate(long txFeeRate) { + var request = SetTxFeeRatePreferenceRequest.newBuilder() + .setTxFeeRatePreference(txFeeRate) + .build(); + return grpcStubs.walletsService.setTxFeeRatePreference(request).getTxFeeRateInfo(); + } + + public TxFeeRateInfo unsetTxFeeRate() { + var request = UnsetTxFeeRatePreferenceRequest.newBuilder().build(); + return grpcStubs.walletsService.unsetTxFeeRatePreference(request).getTxFeeRateInfo(); + } + + public TxInfo getTransaction(String txId) { + var request = GetTransactionRequest.newBuilder() + .setTxId(txId) + .build(); + return grpcStubs.walletsService.getTransaction(request).getTxInfo(); + } + + public void lockWallet() { + var request = LockWalletRequest.newBuilder().build(); + grpcStubs.walletsService.lockWallet(request); + } + + public void unlockWallet(String walletPassword, long timeout) { + var request = UnlockWalletRequest.newBuilder() + .setPassword(walletPassword) + .setTimeout(timeout).build(); + grpcStubs.walletsService.unlockWallet(request); + } + + public void removeWalletPassword(String walletPassword) { + var request = RemoveWalletPasswordRequest.newBuilder() + .setPassword(walletPassword).build(); + grpcStubs.walletsService.removeWalletPassword(request); + } + + public void setWalletPassword(String walletPassword) { + var request = SetWalletPasswordRequest.newBuilder() + .setPassword(walletPassword).build(); + grpcStubs.walletsService.setWalletPassword(request); + } + + public void setWalletPassword(String oldWalletPassword, String newWalletPassword) { + var request = SetWalletPasswordRequest.newBuilder() + .setPassword(oldWalletPassword) + .setNewPassword(newWalletPassword).build(); + grpcStubs.walletsService.setWalletPassword(request); + } +} diff --git a/cli/src/test/java/bisq/cli/opts/EditOfferOptionParserTest.java b/cli/src/test/java/bisq/cli/opts/EditOfferOptionParserTest.java new file mode 100644 index 00000000000..ccabec4ea88 --- /dev/null +++ b/cli/src/test/java/bisq/cli/opts/EditOfferOptionParserTest.java @@ -0,0 +1,346 @@ +package bisq.cli.opts; + +import org.junit.jupiter.api.Test; + +import static bisq.cli.Method.editoffer; +import static bisq.cli.opts.EditOfferOptionParser.OPT_ENABLE_IGNORED; +import static bisq.cli.opts.EditOfferOptionParser.OPT_ENABLE_OFF; +import static bisq.cli.opts.EditOfferOptionParser.OPT_ENABLE_ON; +import static bisq.cli.opts.OptLabel.*; +import static bisq.proto.grpc.EditOfferRequest.EditType.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +// This opt parser test ahs the most thorough coverage, +// and is a reference for other opt parser tests. +public class EditOfferOptionParserTest { + + private static final String PASSWORD_OPT = "--" + OPT_PASSWORD + "=" + "xyz"; + + @Test + public void testEditOfferWithMissingOfferIdOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name() + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("no offer id specified", exception.getMessage()); + } + + @Test + public void testEditOfferWithoutAnyOptsShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("no edit details specified", exception.getMessage()); + } + + @Test + public void testEditOfferWithEmptyEnableOptValueShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_ENABLE + "=" // missing opt value + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("invalid enable value specified, must be true|false", + exception.getMessage()); + } + + @Test + public void testEditOfferWithMissingEnableValueShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_ENABLE // missing equals sign & opt value + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("invalid enable value specified, must be true|false", + exception.getMessage()); + } + + @Test + public void testEditOfferWithInvalidEnableValueShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_ENABLE + "=0" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("invalid enable value specified, must be true|false", + exception.getMessage()); + } + + @Test + public void testEditOfferWithMktPriceOptAndFixedPriceOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_MKT_PRICE_MARGIN + "=0.11", + "--" + OPT_FIXED_PRICE + "=50000.0000" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("cannot specify market price margin and fixed price", + exception.getMessage()); + } + + @Test + public void testEditOfferWithFixedPriceOptAndTriggerPriceOptShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_FIXED_PRICE + "=50000.0000", + "--" + OPT_TRIGGER_PRICE + "=51000.0000" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("trigger price cannot be set on fixed price offers", + exception.getMessage()); + } + + @Test + public void testEditOfferActivationStateOnly() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_ENABLE + "=" + "true" + }; + EditOfferOptionParser parser = new EditOfferOptionParser(args).parse(); + assertEquals(ACTIVATION_STATE_ONLY, parser.getOfferEditType()); + assertEquals(OPT_ENABLE_ON, parser.getEnableAsSignedInt()); + } + + @Test + public void testEditOfferFixedPriceWithoutOptValueShouldThrowException1() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_FIXED_PRICE + "=" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("no fixed price specified", + exception.getMessage()); + } + + @Test + public void testEditOfferFixedPriceWithoutOptValueShouldThrowException2() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_FIXED_PRICE + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("no fixed price specified", + exception.getMessage()); + } + + @Test + public void testEditOfferFixedPriceOnly() { + String fixedPriceAsString = "50000.0000"; + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_FIXED_PRICE + "=" + fixedPriceAsString + }; + EditOfferOptionParser parser = new EditOfferOptionParser(args).parse(); + assertEquals(FIXED_PRICE_ONLY, parser.getOfferEditType()); + assertEquals(fixedPriceAsString, parser.getFixedPrice()); + assertFalse(parser.isUsingMktPriceMargin()); + assertEquals("0.00", parser.getMktPriceMargin()); + assertEquals(OPT_ENABLE_IGNORED, parser.getEnableAsSignedInt()); + } + + @Test + public void testEditOfferFixedPriceAndActivationStateOnly() { + String fixedPriceAsString = "50000.0000"; + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_FIXED_PRICE + "=" + fixedPriceAsString, + "--" + OPT_ENABLE + "=" + "false" + }; + EditOfferOptionParser parser = new EditOfferOptionParser(args).parse(); + assertEquals(FIXED_PRICE_AND_ACTIVATION_STATE, parser.getOfferEditType()); + assertEquals(fixedPriceAsString, parser.getFixedPrice()); + assertFalse(parser.isUsingMktPriceMargin()); + assertEquals("0.00", parser.getMktPriceMargin()); + assertEquals(OPT_ENABLE_OFF, parser.getEnableAsSignedInt()); + } + + @Test + public void testEditOfferMktPriceMarginOnly() { + String mktPriceMarginAsString = "0.25"; + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_MKT_PRICE_MARGIN + "=" + mktPriceMarginAsString + }; + EditOfferOptionParser parser = new EditOfferOptionParser(args).parse(); + assertEquals(MKT_PRICE_MARGIN_ONLY, parser.getOfferEditType()); + assertTrue(parser.isUsingMktPriceMargin()); + assertEquals(mktPriceMarginAsString, parser.getMktPriceMargin()); + assertEquals("0", parser.getTriggerPrice()); + assertEquals(OPT_ENABLE_IGNORED, parser.getEnableAsSignedInt()); + } + + @Test + public void testEditOfferMktPriceMarginWithoutOptValueShouldThrowException() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_MKT_PRICE_MARGIN + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("no mkt price margin specified", + exception.getMessage()); + } + + @Test + public void testEditOfferMktPriceMarginAndActivationStateOnly() { + String mktPriceMarginAsString = "0.15"; + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_MKT_PRICE_MARGIN + "=" + mktPriceMarginAsString, + "--" + OPT_ENABLE + "=" + "false" + }; + EditOfferOptionParser parser = new EditOfferOptionParser(args).parse(); + assertEquals(MKT_PRICE_MARGIN_AND_ACTIVATION_STATE, parser.getOfferEditType()); + assertTrue(parser.isUsingMktPriceMargin()); + assertEquals(mktPriceMarginAsString, parser.getMktPriceMargin()); + assertEquals("0", parser.getTriggerPrice()); + assertEquals(OPT_ENABLE_OFF, parser.getEnableAsSignedInt()); + } + + @Test + public void testEditTriggerPriceOnly() { + String triggerPriceAsString = "50000.0000"; + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_TRIGGER_PRICE + "=" + triggerPriceAsString + }; + EditOfferOptionParser parser = new EditOfferOptionParser(args).parse(); + assertEquals(TRIGGER_PRICE_ONLY, parser.getOfferEditType()); + assertEquals(triggerPriceAsString, parser.getTriggerPrice()); + assertTrue(parser.isUsingMktPriceMargin()); + assertEquals("0.00", parser.getMktPriceMargin()); + assertEquals(OPT_ENABLE_IGNORED, parser.getEnableAsSignedInt()); + } + + @Test + public void testEditTriggerPriceWithoutOptValueShouldThrowException1() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_TRIGGER_PRICE + "=" + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("no trigger price specified", + exception.getMessage()); + } + + @Test + public void testEditTriggerPriceWithoutOptValueShouldThrowException2() { + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_TRIGGER_PRICE + }; + Throwable exception = assertThrows(RuntimeException.class, () -> + new EditOfferOptionParser(args).parse()); + assertEquals("no trigger price specified", + exception.getMessage()); + } + + @Test + public void testEditTriggerPriceAndActivationStateOnly() { + String triggerPriceAsString = "50000.0000"; + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_TRIGGER_PRICE + "=" + triggerPriceAsString, + "--" + OPT_ENABLE + "=" + "true" + }; + EditOfferOptionParser parser = new EditOfferOptionParser(args).parse(); + assertEquals(TRIGGER_PRICE_AND_ACTIVATION_STATE, parser.getOfferEditType()); + assertEquals(triggerPriceAsString, parser.getTriggerPrice()); + assertTrue(parser.isUsingMktPriceMargin()); + assertEquals("0.00", parser.getMktPriceMargin()); + assertEquals("0", parser.getFixedPrice()); + assertEquals(OPT_ENABLE_ON, parser.getEnableAsSignedInt()); + } + + @Test + public void testEditMKtPriceMarginAndTriggerPrice() { + String mktPriceMarginAsString = "0.25"; + String triggerPriceAsString = "50000.0000"; + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_MKT_PRICE_MARGIN + "=" + mktPriceMarginAsString, + "--" + OPT_TRIGGER_PRICE + "=" + triggerPriceAsString + }; + EditOfferOptionParser parser = new EditOfferOptionParser(args).parse(); + assertEquals(MKT_PRICE_MARGIN_AND_TRIGGER_PRICE, parser.getOfferEditType()); + assertEquals(triggerPriceAsString, parser.getTriggerPrice()); + assertTrue(parser.isUsingMktPriceMargin()); + assertEquals(mktPriceMarginAsString, parser.getMktPriceMargin()); + assertEquals("0", parser.getFixedPrice()); + assertEquals(OPT_ENABLE_IGNORED, parser.getEnableAsSignedInt()); + } + + @Test + public void testEditMKtPriceMarginAndTriggerPriceAndEnableState() { + String mktPriceMarginAsString = "0.25"; + String triggerPriceAsString = "50000.0000"; + String[] args = new String[]{ + PASSWORD_OPT, + editoffer.name(), + "--" + OPT_OFFER_ID + "=" + "ABC-OFFER-ID", + "--" + OPT_MKT_PRICE_MARGIN + "=" + mktPriceMarginAsString, + "--" + OPT_TRIGGER_PRICE + "=" + triggerPriceAsString, + "--" + OPT_ENABLE + "=" + "FALSE" + }; + EditOfferOptionParser parser = new EditOfferOptionParser(args).parse(); + assertEquals(MKT_PRICE_MARGIN_AND_TRIGGER_PRICE_AND_ACTIVATION_STATE, parser.getOfferEditType()); + assertEquals(triggerPriceAsString, parser.getTriggerPrice()); + assertTrue(parser.isUsingMktPriceMargin()); + assertEquals(mktPriceMarginAsString, parser.getMktPriceMargin()); + assertEquals("0", parser.getFixedPrice()); + assertEquals(OPT_ENABLE_OFF, parser.getEnableAsSignedInt()); + } +} diff --git a/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java b/cli/src/test/java/bisq/cli/opts/OptionParsersTest.java similarity index 97% rename from cli/src/test/java/bisq/cli/opt/OptionParsersTest.java rename to cli/src/test/java/bisq/cli/opts/OptionParsersTest.java index 951b56a5e3e..58b8712fe90 100644 --- a/cli/src/test/java/bisq/cli/opt/OptionParsersTest.java +++ b/cli/src/test/java/bisq/cli/opts/OptionParsersTest.java @@ -1,4 +1,4 @@ -package bisq.cli.opt; +package bisq.cli.opts; import org.junit.jupiter.api.Test; @@ -11,13 +11,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; - -import bisq.cli.opts.CancelOfferOptionParser; -import bisq.cli.opts.CreateCryptoCurrencyPaymentAcctOptionParser; -import bisq.cli.opts.CreateOfferOptionParser; -import bisq.cli.opts.CreatePaymentAcctOptionParser; - - public class OptionParsersTest { private static final String PASSWORD_OPT = "--" + OPT_PASSWORD + "=" + "xyz"; @@ -178,7 +171,7 @@ public void testCreatePaymentAcctOptParserWithInvalidPaymentFormOptValueShouldTh new CreatePaymentAcctOptionParser(args).parse()); if (System.getProperty("os.name").toLowerCase().indexOf("win") >= 0) assertEquals("json payment account form '\\tmp\\milkyway\\solarsystem\\mars' could not be found", - exception.getMessage()); + exception.getMessage()); else assertEquals("json payment account form '/tmp/milkyway/solarsystem/mars' could not be found", exception.getMessage()); diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java index fe5bbceba58..2fb5c39c5a0 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,32 +159,30 @@ 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) { coreOffersService.cancelOffer(id); } + public boolean isMyOffer(String id) { + return coreOffersService.isMyOffer(id); + } + /////////////////////////////////////////////////////////////////////////////////////////// // PaymentAccounts /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/bisq/core/api/CoreOffersService.java b/core/src/main/java/bisq/core/api/CoreOffersService.java index e18a760c077..04ce9c2ceb3 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; @@ -42,6 +45,7 @@ import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -52,9 +56,14 @@ import static bisq.common.util.MathUtils.roundDoubleToLong; import static bisq.common.util.MathUtils.scaleUpByPowerOf10; import static bisq.core.locale.CurrencyUtil.isCryptoCurrency; +import static bisq.core.offer.Offer.State; 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 bisq.proto.grpc.EditOfferRequest.EditType.*; import static java.lang.String.format; import static java.util.Comparator.comparing; @@ -62,8 +71,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 +88,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 +100,7 @@ public CoreOffersService(CoreContext coreContext, OfferFilter offerFilter, OpenOfferManager openOfferManager, OfferUtil offerUtil, + PriceFeedService priceFeedService, User user) { this.coreContext = coreContext; this.keyRing = keyRing; @@ -96,6 +110,7 @@ public CoreOffersService(CoreContext coreContext, this.offerFilter = offerFilter; this.openOfferManager = openOfferManager; this.offerUtil = offerUtil; + this.priceFeedService = priceFeedService; this.user = user; } @@ -108,10 +123,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 +140,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 +152,13 @@ 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))); + } + + boolean isMyOffer(String id) { + return openOfferManager.getOpenOfferById(id) + .filter(open -> open.getOffer().isMyOffer(keyRing)) + .isPresent(); } // Create and place new offer. @@ -193,47 +214,67 @@ 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 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, + editedEnable, + editType).validate(); + log.info("Validated 'editoffer' params offerId={}" + + "\n\teditedPriceAsString={}" + + "\n\teditedUseMarketBasedPrice={}" + + "\n\teditedMarketPriceMargin={}" + + "\n\teditedTriggerPrice={}" + + "\n\teditedEnable={}" + + "\n\teditType={}", + offerId, + editedPriceAsString, + editedUseMarketBasedPrice, + editedMarketPriceMargin, + editedTriggerPrice, + editedEnable, + editType); + OpenOffer.State currentOfferState = openOffer.getState(); + // Client sent (sint32) editedEnable, not a bool (with default=false). + // If editedEnable = -1, do not change current state + // If editedEnable = 0, set state = AVAILABLE + // If editedEnable = 1, set state = DEACTIVATED + OpenOffer.State newOfferState = editedEnable < 0 + ? currentOfferState + : editedEnable > 0 ? AVAILABLE : DEACTIVATED; + OfferPayload editedPayload = getMergedOfferPayload(openOffer, + editedPriceAsString, + editedMarketPriceMargin, + editType); + Offer editedOffer = new Offer(editedPayload); + priceFeedService.setCurrencyCode(openOffer.getOffer().getOfferPayload().getCurrencyCode()); + editedOffer.setPriceFeedService(priceFeedService); + editedOffer.setState(State.AVAILABLE); + openOfferManager.editOpenOfferStart(openOffer, + () -> log.info("EditOpenOfferStart: offer {}", openOffer.getId()), + log::error); + openOfferManager.editOpenOfferPublish(editedOffer, + editedTriggerPrice, + newOfferState, + () -> log.info("EditOpenOfferPublish: offer {}", openOffer.getId()), + log::error); } void cancelOffer(String id) { - Offer offer = getMyOffer(id); - openOfferManager.removeOffer(offer, + OpenOffer openOffer = getMyOffer(id); + openOfferManager.removeOffer(openOffer.getOffer(), () -> { }, - errorMessage -> { - throw new IllegalStateException(errorMessage); - }); - } - - 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); - } + log::error); } private void placeOffer(Offer offer, @@ -252,6 +293,54 @@ private void placeOffer(Offer offer, throw new IllegalStateException(offer.getErrorMessage()); } + private OfferPayload getMergedOfferPayload(OpenOffer openOffer, + String editedPriceAsString, + double editedMarketPriceMargin, + EditType editType) { + // API supports editing (1) price, OR (2) marketPriceMargin & useMarketBasedPrice + // OfferPayload fields. API does not support editing payment acct or currency + // code fields. Note: triggerPrice isDeactivated fields are in OpenOffer, not + // in OfferPayload. + Offer offer = openOffer.getOffer(); + String currencyCode = offer.getOfferPayload().getCurrencyCode(); + boolean isEditingPrice = editType.equals(FIXED_PRICE_ONLY) || editType.equals(FIXED_PRICE_AND_ACTIVATION_STATE); + Price editedPrice; + if (isEditingPrice) { + editedPrice = Price.valueOf(currencyCode, priceStringToLong(editedPriceAsString, currencyCode)); + } else { + editedPrice = offer.getPrice(); + } + boolean isUsingMktPriceMargin = editType.equals(MKT_PRICE_MARGIN_ONLY) + || editType.equals(MKT_PRICE_MARGIN_AND_ACTIVATION_STATE) + || editType.equals(TRIGGER_PRICE_ONLY) + || editType.equals(TRIGGER_PRICE_AND_ACTIVATION_STATE) + || editType.equals(MKT_PRICE_MARGIN_AND_TRIGGER_PRICE) + || editType.equals(MKT_PRICE_MARGIN_AND_TRIGGER_PRICE_AND_ACTIVATION_STATE); + MutableOfferPayloadFields mutableOfferPayloadFields = new MutableOfferPayloadFields( + Objects.requireNonNull(editedPrice).getValue(), + isUsingMktPriceMargin ? exactMultiply(editedMarketPriceMargin, 0.01) : 0.00, + isUsingMktPriceMargin, + offer.getOfferPayload().getBaseCurrencyCode(), + offer.getOfferPayload().getCounterCurrencyCode(), + offer.getPaymentMethod().getId(), + offer.getMakerPaymentAccountId(), + offer.getOfferPayload().getCountryCode(), + offer.getOfferPayload().getAcceptedCountryCodes(), + offer.getOfferPayload().getBankId(), + offer.getOfferPayload().getAcceptedBankIds()); + log.info("Merging OfferPayload with {}", mutableOfferPayloadFields); + 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 +350,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..7a9840c3cdb --- /dev/null +++ b/core/src/main/java/bisq/core/api/EditOfferValidator.java @@ -0,0 +1,141 @@ +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 bisq.core.locale.CurrencyUtil.isCryptoCurrency; +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 int editedEnable; + private final EditOfferRequest.EditType editType; + + private final boolean isZeroEditedFixedPriceString; + private final boolean isZeroEditedTriggerPrice; + + EditOfferValidator(OpenOffer currentlyOpenOffer, + String editedPriceAsString, + boolean editedUseMarketBasedPrice, + double editedMarketPriceMargin, + long editedTriggerPrice, + int editedEnable, + EditOfferRequest.EditType editType) { + this.currentlyOpenOffer = currentlyOpenOffer; + this.editedPriceAsString = editedPriceAsString; + this.editedUseMarketBasedPrice = editedUseMarketBasedPrice; + this.editedMarketPriceMargin = editedMarketPriceMargin; + this.editedTriggerPrice = editedTriggerPrice; + this.editedEnable = editedEnable; + this.editType = editType; + + this.isZeroEditedFixedPriceString = new BigDecimal(editedPriceAsString).doubleValue() == 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: + case MKT_PRICE_MARGIN_AND_TRIGGER_PRICE: + case MKT_PRICE_MARGIN_AND_TRIGGER_PRICE_AND_ACTIVATION_STATE: { + checkNotAltcoinOffer(); + validateEditedTriggerPrice(); + validateEditedMarketPriceMargin(); + break; + } + default: + break; + } + } + + private void validateEditedActivationState() { + if (editedEnable < 0) + throw new IllegalStateException( + format("programmer error: the 'enable' request parameter does not" + + " indicate activation state of offer with id '%s' should be changed.", + currentlyOpenOffer.getId())); + } + + private void validateEditedFixedPrice() { + if (currentlyOpenOffer.getOffer().isUseMarketBasedPrice()) + log.info("Attempting to change mkt price margin based offer with id '{}' 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 '{}' to mkt price margin based offer.", + 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 (!currentlyOpenOffer.getOffer().isUseMarketBasedPrice() + && !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 (editedTriggerPrice < 0) + throw new IllegalStateException( + format("programmer error: cannot set trigger price to a negative value" + + " in offer with id '%s'", + currentlyOpenOffer.getId())); + } + + private void checkNotAltcoinOffer() { + if (isCryptoCurrency(currentlyOpenOffer.getOffer().getCurrencyCode())) { + throw new IllegalStateException( + format("cannot set mkt price margin or trigger price on fixed price altcoin 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..f99df264569 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,8 @@ public class OfferInfo implements Payload { private final String counterCurrencyCode; private final long date; private final String state; - + private final boolean isActivated; + private boolean isMyOffer; public OfferInfo(OfferInfoBuilder builder) { this.id = builder.id; @@ -87,20 +89,30 @@ public OfferInfo(OfferInfoBuilder builder) { this.counterCurrencyCode = builder.counterCurrencyCode; this.date = builder.date; this.state = builder.state; + this.isActivated = builder.isActivated; + this.isMyOffer = builder.isMyOffer; + } + // Allow isMyOffer to be set on new offers' OfferInfo instances. + public void setIsMyOffer(boolean myOffer) { + isMyOffer = myOffer; } public static OfferInfo toOfferInfo(Offer offer) { - return getOfferInfoBuilder(offer).build(); + // Assume the offer is not mine, but isMyOffer can be reset to true, i.e., when + // calling TradeInfo toTradeInfo(Trade trade, String role, boolean isMyOffer); + return getOfferInfoBuilder(offer, false).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) { + // An OpenOffer is always my offer. + return getOfferInfoBuilder(openOffer.getOffer(), true) + .withTriggerPrice(openOffer.getTriggerPrice()) + .withIsActivated(!openOffer.isDeactivated()) + .build(); } - private static OfferInfoBuilder getOfferInfoBuilder(Offer offer) { + private static OfferInfoBuilder getOfferInfoBuilder(Offer offer, boolean isMyOffer) { return new OfferInfoBuilder() .withId(offer.getId()) .withDirection(offer.getDirection().name()) @@ -123,7 +135,8 @@ private static OfferInfoBuilder getOfferInfoBuilder(Offer offer) { .withBaseCurrencyCode(offer.getOfferPayload().getBaseCurrencyCode()) .withCounterCurrencyCode(offer.getOfferPayload().getCounterCurrencyCode()) .withDate(offer.getDate().getTime()) - .withState(offer.getState().name()); + .withState(offer.getState().name()) + .withIsMyOffer(isMyOffer); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -156,6 +169,8 @@ public bisq.proto.grpc.OfferInfo toProtoMessage() { .setCounterCurrencyCode(counterCurrencyCode) .setDate(date) .setState(state) + .setIsActivated(isActivated) + .setIsMyOffer(isMyOffer) .build(); } @@ -185,6 +200,8 @@ public static OfferInfo fromProto(bisq.proto.grpc.OfferInfo proto) { .withCounterCurrencyCode(proto.getCounterCurrencyCode()) .withDate(proto.getDate()) .withState(proto.getState()) + .withIsActivated(proto.getIsActivated()) + .withIsMyOffer(proto.getIsMyOffer()) .build(); } @@ -218,6 +235,8 @@ public static class OfferInfoBuilder { private String counterCurrencyCode; private long date; private String state; + private boolean isActivated; + private boolean isMyOffer; public OfferInfoBuilder withId(String id) { this.id = id; @@ -334,6 +353,16 @@ public OfferInfoBuilder withState(String state) { return this; } + public OfferInfoBuilder withIsActivated(boolean isActivated) { + this.isActivated = isActivated; + return this; + } + + public OfferInfoBuilder withIsMyOffer(boolean isMyOffer) { + this.isMyOffer = isMyOffer; + 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 index 078e5ee4d9c..5779baf348e 100644 --- a/core/src/main/java/bisq/core/api/model/TradeInfo.java +++ b/core/src/main/java/bisq/core/api/model/TradeInfo.java @@ -92,11 +92,11 @@ public TradeInfo(TradeInfoBuilder builder) { this.contract = builder.contract; } - public static TradeInfo toTradeInfo(Trade trade) { - return toTradeInfo(trade, null); + public static TradeInfo toNewTradeInfo(Trade trade) { + return toTradeInfo(trade, null, false); } - public static TradeInfo toTradeInfo(Trade trade, String role) { + public static TradeInfo toTradeInfo(Trade trade, String role, boolean isMyOffer) { ContractInfo contractInfo; if (trade.getContract() != null) { Contract contract = trade.getContract(); @@ -116,8 +116,10 @@ public static TradeInfo toTradeInfo(Trade trade, String role) { contractInfo = ContractInfo.emptyContract.get(); } + OfferInfo offerInfo = toOfferInfo(trade.getOffer()); + offerInfo.setIsMyOffer(isMyOffer); return new TradeInfoBuilder() - .withOffer(toOfferInfo(trade.getOffer())) + .withOffer(offerInfo) .withTradeId(trade.getId()) .withShortId(trade.getShortId()) .withDate(trade.getDate().getTime()) diff --git a/core/src/main/java/bisq/core/offer/MutableOfferPayloadFields.java b/core/src/main/java/bisq/core/offer/MutableOfferPayloadFields.java new file mode 100644 index 00000000000..56700fd63aa --- /dev/null +++ b/core/src/main/java/bisq/core/offer/MutableOfferPayloadFields.java @@ -0,0 +1,89 @@ +/* + * 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 java.util.List; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import javax.annotation.Nullable; + +/** + * The set of editable OfferPayload fields. + */ +@Getter +@Setter +@ToString +public final class MutableOfferPayloadFields { + + private final long price; + private final double marketPriceMargin; + private final boolean useMarketBasedPrice; + private final String baseCurrencyCode; + private final String counterCurrencyCode; + private final String paymentMethodId; + private final String makerPaymentAccountId; + @Nullable + private final String countryCode; + @Nullable + private final List acceptedCountryCodes; + @Nullable + private final String bankId; + @Nullable + private final List acceptedBankIds; + + public MutableOfferPayloadFields(OfferPayload offerPayload) { + this(offerPayload.getPrice(), + offerPayload.getMarketPriceMargin(), + offerPayload.isUseMarketBasedPrice(), + offerPayload.getBaseCurrencyCode(), + offerPayload.getCounterCurrencyCode(), + offerPayload.getPaymentMethodId(), + offerPayload.getMakerPaymentAccountId(), + offerPayload.getCountryCode(), + offerPayload.getAcceptedCountryCodes(), + offerPayload.getBankId(), + offerPayload.getAcceptedBankIds()); + } + + public MutableOfferPayloadFields(long price, + double marketPriceMargin, + boolean useMarketBasedPrice, + String baseCurrencyCode, + String counterCurrencyCode, + String paymentMethodId, + String makerPaymentAccountId, + @Nullable String countryCode, + @Nullable List acceptedCountryCodes, + @Nullable String bankId, + @Nullable List acceptedBankIds) { + this.price = price; + this.marketPriceMargin = marketPriceMargin; + this.useMarketBasedPrice = useMarketBasedPrice; + this.baseCurrencyCode = baseCurrencyCode; + this.counterCurrencyCode = counterCurrencyCode; + this.paymentMethodId = paymentMethodId; + this.makerPaymentAccountId = makerPaymentAccountId; + this.countryCode = countryCode; + this.acceptedCountryCodes = acceptedCountryCodes; + this.bankId = bankId; + this.acceptedBankIds = acceptedBankIds; + } +} diff --git a/core/src/main/java/bisq/core/offer/OfferUtil.java b/core/src/main/java/bisq/core/offer/OfferUtil.java index 8ea47fd2be0..91c894feb0e 100644 --- a/core/src/main/java/bisq/core/offer/OfferUtil.java +++ b/core/src/main/java/bisq/core/offer/OfferUtil.java @@ -364,6 +364,53 @@ public void validateOfferData(double buyerSecurityDeposit, Res.get("offerbook.warning.paymentMethodBanned")); } + // Returns an edited payload: a merge of the original offerPayload and + // editedOfferPayload fields. Mutable fields are sourced from + // mutableOfferPayloadFields param, e.g., payment account details, price, etc. + // Immutable fields are sourced from the original openOffer param. + public OfferPayload getMergedOfferPayload(OpenOffer openOffer, + MutableOfferPayloadFields mutableOfferPayloadFields) { + OfferPayload originalOfferPayload = openOffer.getOffer().getOfferPayload(); + return new OfferPayload(originalOfferPayload.getId(), + originalOfferPayload.getDate(), + originalOfferPayload.getOwnerNodeAddress(), + originalOfferPayload.getPubKeyRing(), + originalOfferPayload.getDirection(), + mutableOfferPayloadFields.getPrice(), + mutableOfferPayloadFields.getMarketPriceMargin(), + mutableOfferPayloadFields.isUseMarketBasedPrice(), + originalOfferPayload.getAmount(), + originalOfferPayload.getMinAmount(), + mutableOfferPayloadFields.getBaseCurrencyCode(), + mutableOfferPayloadFields.getCounterCurrencyCode(), + originalOfferPayload.getArbitratorNodeAddresses(), + originalOfferPayload.getMediatorNodeAddresses(), + mutableOfferPayloadFields.getPaymentMethodId(), + mutableOfferPayloadFields.getMakerPaymentAccountId(), + originalOfferPayload.getOfferFeePaymentTxId(), + mutableOfferPayloadFields.getCountryCode(), + mutableOfferPayloadFields.getAcceptedCountryCodes(), + mutableOfferPayloadFields.getBankId(), + mutableOfferPayloadFields.getAcceptedBankIds(), + originalOfferPayload.getVersionNr(), + originalOfferPayload.getBlockHeightAtOfferCreation(), + originalOfferPayload.getTxFee(), + originalOfferPayload.getMakerFee(), + originalOfferPayload.isCurrencyForMakerFeeBtc(), + originalOfferPayload.getBuyerSecurityDeposit(), + originalOfferPayload.getSellerSecurityDeposit(), + originalOfferPayload.getMaxTradeLimit(), + originalOfferPayload.getMaxTradePeriod(), + originalOfferPayload.isUseAutoClose(), + originalOfferPayload.isUseReOpenAfterAutoClose(), + originalOfferPayload.getLowerClosePrice(), + originalOfferPayload.getUpperClosePrice(), + originalOfferPayload.isPrivateOffer(), + originalOfferPayload.getHashOfChallenge(), + originalOfferPayload.getExtraDataMap(), + originalOfferPayload.getProtocolVersion()); + } + private Optional getFeeInUserFiatCurrency(Coin makerFee, boolean isCurrencyForMakerFeeBtc, String userCurrencyCode, diff --git a/core/src/main/resources/help/editoffer-help.txt b/core/src/main/resources/help/editoffer-help.txt new file mode 100644 index 00000000000..b56bc6e8bb7 --- /dev/null +++ b/core/src/main/resources/help/editoffer-help.txt @@ -0,0 +1,95 @@ +editoffer + +NAME +---- +editoffer - edit an existing offer to buy or sell BTC + +SYNOPSIS +-------- +editoffer + --offer-id= + [--market-price-margin=] + [--trigger-price=] + [--fixed-price=] + [--enabled=] + +DESCRIPTION +----------- +Edit an existing offer. Offers can be changed in the following ways: + + Change a fixed-price offer to a market-price-margin based offer. + Change a market-price-margin based offer to a fixed-price offer. + Change a market-price-margin. + Change a fixed-price. + Define, change, or remove a market-price-margin based offer's trigger price. + Disable an enabled offer. + Enable a disabled offer. + +OPTIONS +------- +--offer-id + The ID of the buy or sell offer to edit. + +--market-price-margin + Changes the % above or below market BTC price, e.g., 1.00 (1%). + A --fixed-price offer can be changed to a --market-price-margin offer with this option. + The --market-price-margin and --trigger-price options can be used in the same editoffer command. + The --market-price-margin and --fixed-price options cannot be used in the same editoffer command. + +--fixed-price + Changes the fixed BTC price in fiat used to buy or sell BTC, e.g., 34000 (USD). + A --market-price-margin offer can be changed to a --fixed-price offer with this option. + The --fixed-price and --market-price-margin options cannot be used in the same editoffer command. + +--trigger-price + Sets the market price for triggering the de-activation of an offer, or defines trigger-price on an + offer that did not have a trigger-price when it was created. + A buy BTC offer is de-activated when the market price rises above the trigger-price. + A sell BTC offer is de-activated when the market price falls below the trigger-price. + Only applies to market-price-margin based offers; a fixed-price offer's trigger-price is ignored. + The --fixed-price and --trigger-price options cannot be used in the same editoffer command. + +--enabled + If true, enables a disabled offer. Does nothing if offer is already enabled. + If false, disabled an enabled offer. Does nothing if offer is already disabled. + +EXAMPLES +-------- + +To change a fixed-price offer with ID 83e8b2e2-51b6-4f39-a748-3ebd29c22aea + to a 0.10% market-price-margin based offer: +$ ./bisq-cli --password=xyz --port=9998 editoffer --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --market-price-margin=0.10 + +To change a market-price-margin based offer with ID 83e8b2e2-51b6-4f39-a748-3ebd29c22aea + to a fixed-price offer: +$ ./bisq-cli --password=xyz --port=9998 editoffer --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --fixed-price=50000.0000 + +To set or change the trigger-price on a market-price-margin + based offer with ID 83e8b2e2-51b6-4f39-a748-3ebd29c22aea: +$ ./bisq-cli --password=xyz --port=9998 editoffer --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --trigger-price=50000.0000 + +To remove a trigger-price on a market-price-margin + based offer with ID 83e8b2e2-51b6-4f39-a748-3ebd29c22aea: +$ ./bisq-cli --password=xyz --port=9998 editoffer --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --trigger-price=0 + +To change the market-price-margin and trigger-price on an + offer with ID 83e8b2e2-51b6-4f39-a748-3ebd29c22aea: +$ ./bisq-cli --password=xyz --port=9998 editoffer --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --market-price-margin=0.05 \ + --trigger-price=50000.0000 + +To disable an offer with ID 83e8b2e2-51b6-4f39-a748-3ebd29c22aea: +$ ./bisq-cli --password=xyz --port=9998 editoffer --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --enable=false + +To enable a disabled offer with ID 83e8b2e2-51b6-4f39-a748-3ebd29c22aea, + and change it from a fixed-price offer to a 0.50% market-price-margin based offer, + and set the trigger-price to 50000.0000: +$ ./bisq-cli --password=xyz --port=9998 editoffer --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \ + --market-price-margin=0.50 \ + --trigger-price=50000.0000 \ + --enable=true diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcOffersService.java index 54658e4c9a9..b1f9212d46b 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() @@ -159,6 +161,7 @@ public void createOffer(CreateOfferRequest req, // This result handling consumer's accept operation will return // the new offer to the gRPC client after async placement is done. OfferInfo offerInfo = toOfferInfo(offer); + offerInfo.setIsMyOffer(true); CreateOfferReply reply = CreateOfferReply.newBuilder() .setOffer(offerInfo.toProtoMessage()) .build(); @@ -170,6 +173,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 +220,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)); }} ))); diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java index 8cfc74f7463..7ffea95e6a5 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcTradesService.java @@ -44,6 +44,7 @@ import lombok.extern.slf4j.Slf4j; +import static bisq.core.api.model.TradeInfo.toNewTradeInfo; import static bisq.core.api.model.TradeInfo.toTradeInfo; import static bisq.daemon.grpc.interceptor.GrpcServiceRateMeteringConfig.getCustomRateMeteringInterceptor; import static bisq.proto.grpc.TradesGrpc.*; @@ -72,9 +73,10 @@ public void getTrade(GetTradeRequest req, StreamObserver responseObserver) { try { Trade trade = coreApi.getTrade(req.getTradeId()); + boolean isMyOffer = coreApi.isMyOffer(trade.getOffer().getId()); String role = coreApi.getTradeRole(req.getTradeId()); var reply = GetTradeReply.newBuilder() - .setTrade(toTradeInfo(trade, role).toProtoMessage()) + .setTrade(toTradeInfo(trade, role, isMyOffer).toProtoMessage()) .build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -99,7 +101,7 @@ public void takeOffer(TakeOfferRequest req, req.getPaymentAccountId(), req.getTakerFeeCurrencyCode(), trade -> { - TradeInfo tradeInfo = toTradeInfo(trade); + TradeInfo tradeInfo = toNewTradeInfo(trade); var reply = TakeOfferReply.newBuilder() .setTrade(tradeInfo.toProtoMessage()) .build(); 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 dc7d29c7c08..abf533c4723 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 @@ -28,6 +28,7 @@ import bisq.core.locale.CurrencyUtil; import bisq.core.locale.TradeCurrency; import bisq.core.offer.CreateOfferService; +import bisq.core.offer.MutableOfferPayloadFields; import bisq.core.offer.Offer; import bisq.core.offer.OfferPayload; import bisq.core.offer.OfferUtil; @@ -182,54 +183,12 @@ public void onStartEditOffer(ErrorMessageHandler errorMessageHandler) { } public void onPublishOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { - // editedPayload is a merge of the original offerPayload and newOfferPayload - // fields which are editable are merged in from newOfferPayload (such as payment account details) - // fields which cannot change (most importantly BTC amount) are sourced from the original offerPayload - final OfferPayload offerPayload = openOffer.getOffer().getOfferPayload(); - final OfferPayload newOfferPayload = createAndGetOffer().getOfferPayload(); - final OfferPayload editedPayload = new OfferPayload(offerPayload.getId(), - offerPayload.getDate(), - offerPayload.getOwnerNodeAddress(), - offerPayload.getPubKeyRing(), - offerPayload.getDirection(), - newOfferPayload.getPrice(), - newOfferPayload.getMarketPriceMargin(), - newOfferPayload.isUseMarketBasedPrice(), - offerPayload.getAmount(), - offerPayload.getMinAmount(), - newOfferPayload.getBaseCurrencyCode(), - newOfferPayload.getCounterCurrencyCode(), - offerPayload.getArbitratorNodeAddresses(), - offerPayload.getMediatorNodeAddresses(), - newOfferPayload.getPaymentMethodId(), - newOfferPayload.getMakerPaymentAccountId(), - offerPayload.getOfferFeePaymentTxId(), - newOfferPayload.getCountryCode(), - newOfferPayload.getAcceptedCountryCodes(), - newOfferPayload.getBankId(), - newOfferPayload.getAcceptedBankIds(), - offerPayload.getVersionNr(), - offerPayload.getBlockHeightAtOfferCreation(), - offerPayload.getTxFee(), - offerPayload.getMakerFee(), - offerPayload.isCurrencyForMakerFeeBtc(), - offerPayload.getBuyerSecurityDeposit(), - offerPayload.getSellerSecurityDeposit(), - offerPayload.getMaxTradeLimit(), - offerPayload.getMaxTradePeriod(), - offerPayload.isUseAutoClose(), - offerPayload.isUseReOpenAfterAutoClose(), - offerPayload.getLowerClosePrice(), - offerPayload.getUpperClosePrice(), - offerPayload.isPrivateOffer(), - offerPayload.getHashOfChallenge(), - offerPayload.getExtraDataMap(), - offerPayload.getProtocolVersion()); - + MutableOfferPayloadFields mutableOfferPayloadFields = + new MutableOfferPayloadFields(createAndGetOffer().getOfferPayload()); + final OfferPayload editedPayload = offerUtil.getMergedOfferPayload(openOffer, mutableOfferPayloadFields); final Offer editedOffer = new Offer(editedPayload); editedOffer.setPriceFeedService(priceFeedService); editedOffer.setState(Offer.State.AVAILABLE); - openOfferManager.editOpenOfferPublish(editedOffer, triggerPrice, initialState, () -> { openOffer = null; resultHandler.handleResult(); diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index b8d50bfe4f8..ff2467f713f 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -72,6 +72,8 @@ service Offers { } rpc CreateOffer (CreateOfferRequest) returns (CreateOfferReply) { } + rpc EditOffer (EditOfferRequest) returns (EditOfferReply) { + } rpc CancelOffer (CancelOfferRequest) returns (CancelOfferReply) { } } @@ -128,6 +130,35 @@ message CreateOfferReply { OfferInfo offer = 1; } +message EditOfferRequest { + string id = 1; + string price = 2; + bool useMarketBasedPrice = 3; + double marketPriceMargin = 4; + uint64 triggerPrice = 5; + // Send a signed int, not a bool (with default=false). + // -1 = do not change activation state + // 0 = disable + // 1 = enable + sint32 enable = 6; + // The EditType constricts what offer details can be modified and simplifies param validation. + enum EditType { + ACTIVATION_STATE_ONLY = 0; + FIXED_PRICE_ONLY = 1; + FIXED_PRICE_AND_ACTIVATION_STATE = 2; + MKT_PRICE_MARGIN_ONLY = 3; + MKT_PRICE_MARGIN_AND_ACTIVATION_STATE = 4; + TRIGGER_PRICE_ONLY = 5; + TRIGGER_PRICE_AND_ACTIVATION_STATE = 6; + MKT_PRICE_MARGIN_AND_TRIGGER_PRICE = 7; + MKT_PRICE_MARGIN_AND_TRIGGER_PRICE_AND_ACTIVATION_STATE = 8; + } + EditType editType = 7; +} + +message EditOfferReply { +} + message CancelOfferRequest { string id = 1; } @@ -159,6 +190,8 @@ message OfferInfo { string offerFeePaymentTxId = 21; uint64 txFee = 22; uint64 makerFee = 23; + bool isActivated = 24; + bool isMyOffer = 25; } message AvailabilityResultWithDescription {