Merge pull request #6312 from ghubstan/support-choosing-trade-amount-in-range

API takeoffer:  Let user choose intended trade amount
This commit is contained in:
Christoph Atteneder 2022-08-07 21:36:41 +02:00 committed by GitHub
commit 423d57ae24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 407 additions and 54 deletions

View File

@ -1,6 +1,6 @@
# Bisq API Beta Testing Guide
This guide explains how Bisq Api beta testers can quickly get a test harness running, watch a regtest trade simulation,
This guide explains how Bisq API beta testers can quickly get a test harness running, watch a regtest trade simulation,
and use the CLI to execute trades between Bob and Alice.
Knowledge of Git, Java, and installing bitcoin-core is required.
@ -41,7 +41,7 @@ $ ./gradlew clean build :apitest:installDaoSetup -x test # if you want to ski
$ ./gradlew clean build :apitest:installDaoSetup # if you want to run Bisq tests
```
## Running Api Test Harness
## Running API Test Harness
#### Warning: Never run an API daemon and the [Bisq GUI](https://bisq.network) on the same host at the same time.
@ -118,7 +118,7 @@ Same as described at the top of this document, but your bitcoin-cores `bitcoi
### Description
The regtest trade simulation script `apitest/scripts/trade-simulation.sh` is a useful introduction to the Bisq Api.
The regtest trade simulation script `apitest/scripts/trade-simulation.sh` is a useful introduction to the Bisq API.
The bash scripts output is intended to serve as a tutorial, showing how the CLI can be used to create payment
accounts for Bob and Alice, create an offer, take the offer, and complete a trade.
(The bash script itself is not intended to be as useful as the output.) The output is generated too quickly to
@ -155,9 +155,9 @@ $ apitest/scripts/trade-simulation.sh -d buy -c at -f 30800 -a 0.125
The test harness used by the simulation script described in the previous section can also be used for manual CLI
testing, and you can leave it running as you try the commands described below.
The Apis default server listening port is `9998`, and you do not need to specify a `port=<port>` option in a
CLI command unless you change the servers `apiPort=<listening-port>`. In the test harness, Alices Api port is
`9998`, Bobs is `9999`. When you manually test the Api using the test harness, be aware of the port numbers being
The APIs default server listening port is `9998`, and you do not need to specify a `port=<port>` option in a
CLI command unless you change the servers `apiPort=<listening-port>`. In the test harness, Alices API port is
`9998`, Bobs is `9999`. When you manually test the API using the test harness, be aware of the port numbers being
used in the CLI commands, so you know which server (Bobs or Alices) the CLI is sending requests to.
### CLI Help
@ -278,7 +278,7 @@ $ ./bisq-cli --password=xyz --port=9998 sendbtc --address=<btc-address> --amount
### Withdrawal Transaction Fees
If you have traded using the Bisq UI, you are probably aware of the default network bitcoin withdrawal transaction
fee and custom withdrawal transaction fee user preference in the UIs setting view. The Api uses these same
fee and custom withdrawal transaction fee user preference in the UIs setting view. The API uses these same
withdrawal transaction fee rates, and affords a third as mentioned in the previous section -- withdrawal
transaction fee option in the `sendbsq` and `sendbtc` commands. The `sendbsq` and `sendbtc` commands'
`--tx-fee-rate=<sats/byte>` options override both the default network fee rate, and your custom transaction fee
@ -305,7 +305,7 @@ $ ./bisq-cli --password=xyz unsettxfeerate
### Creating Test Fiat Payment Accounts
Creating a fiat payment account using the Api involves three steps:
Creating a fiat payment account using the API involves three steps:
1. Find the payment-method-id for the payment account type you wish to create. For example, if you want to
create a face-to-face type payment account, find the face-to-face payment-method-id (`F2F`):
@ -376,7 +376,7 @@ $ ./bisq-cli --password=xyz --port=9999 createcryptopaymentacct --account-name=X
### Creating Offers
The createoffer command is the Api's most complex command (so far), but CLI posix-style options are self-explanatory,
The createoffer command is the API's most complex command (so far), but CLI posix-style options are self-explanatory,
and CLI `createoffer` command help gives you specific information about each option.
```
$ ./bisq-cli --password=xyz --port=9998 createoffer --help
@ -597,11 +597,15 @@ with the `takeoffer` command:
$ ./bisq-cli --password=xyz --port=9998 takeoffer \
--offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \
--payment-account-id=fe20cdbd-22be-4b8a-a4b6-d2608ff09d6e \
--amount=0.125
--fee-currency=btc
```
Depending on the offer type, the taken offer will be used to (1) create a trade contract, or (2) execute a BSQ swap.
The next section describes how to use the Api to execute a trade. The following <b>Completing a BSQ Swap Trade</b>
The value passed with the optional `--amount` parameter must be between the offer's min-amount and amount values.
If the `--amount` parameter is omitted, the intended trade amount will equal the taken offer's amount.
The next section describes how to use the API to execute a trade. The following <b>Completing a BSQ Swap Trade</b>
section explains how to use the `takeoffer` command to complete a BSQ swap.
### Completing Trade Protocol
@ -671,7 +675,7 @@ $ ./bisq-cli --password=xyz --port=9998 takeoffer --offer-id=Xge8b2e2-51b6-3TOOB
## Shutting Down Test Harness
The test harness should cleanly shutdown all the background apps in proper order after entering ^C.
The test harness should cleanly shut down all the background apps in proper order after entering ^C.
Once shutdown, all Bisq and bitcoin-core data files are left in the state they were in at shutdown time,
so they and logs can be examined after a test run. All datafiles will be refreshed the next time the test harness

View File

@ -61,21 +61,25 @@ public class AbstractTradeTest extends AbstractOfferTest {
protected final TradeInfo takeAlicesOffer(String offerId,
String paymentAccountId,
String takerFeeCurrencyCode) {
String takerFeeCurrencyCode,
long intendedTradeAmount) {
return takeAlicesOffer(offerId,
paymentAccountId,
takerFeeCurrencyCode,
intendedTradeAmount,
true);
}
protected final TradeInfo takeAlicesOffer(String offerId,
String paymentAccountId,
String takerFeeCurrencyCode,
long intendedTradeAmount,
boolean generateBtcBlock) {
@SuppressWarnings("ConstantConditions")
var trade = bobClient.takeOffer(offerId,
paymentAccountId,
takerFeeCurrencyCode);
takerFeeCurrencyCode,
intendedTradeAmount);
assertNotNull(trade);
assertEquals(offerId, trade.getTradeId());

View File

@ -120,7 +120,7 @@ public class BsqSwapBuyBtcTradeTest extends AbstractTradeTest {
sleep(3_000);
var swapTrade = bobClient.takeBsqSwapOffer(availableSwapOffer.getId());
var swapTrade = bobClient.takeBsqSwapOffer(availableSwapOffer.getId(), 0L);
tradeId = swapTrade.getTradeId(); // Cache the tradeId for following test case(s).
log.debug("BsqSwap Trade at PREPARATION:\n{}", toTradeDetailTable.apply(swapTrade));
assertEquals(PREPARATION.name(), swapTrade.getState());

View File

@ -118,7 +118,7 @@ public class BsqSwapSellBtcTradeTest extends AbstractTradeTest {
sleep(10_000);
var swapTrade = bobClient.takeBsqSwapOffer(availableSwapOffer.getId());
var swapTrade = bobClient.takeBsqSwapOffer(availableSwapOffer.getId(), 0L);
tradeId = swapTrade.getTradeId(); // Cache the tradeId for following test case(s).
log.debug("BsqSwap Trade at PREPARATION:\n{}", toTradeDetailTable.apply(swapTrade));
assertEquals(PREPARATION.name(), swapTrade.getState());

View File

@ -107,6 +107,7 @@ public class InsufficientBtcToTakeOfferTest extends AbstractTradeTest {
takeAlicesOffer(offerId,
bobsUsdAccount.getId(),
TRADE_FEE_CURRENCY_CODE,
12_500_000L,
false));
String expectedExceptionMessage =
format("UNAVAILABLE: wallet has insufficient btc to take offer with id '%s'", offerId);

View File

@ -87,9 +87,11 @@ public class TakeBuyBSQOfferTest extends AbstractTradeTest {
var alicesBsqOffers = aliceClient.getMyOffers(btcTradeDirection, BSQ);
assertEquals(1, alicesBsqOffers.size());
var intendedTradeAmount = 10_000_000L;
var trade = takeAlicesOffer(offerId,
bobsLegacyBsqAcct.getId(),
TRADE_FEE_CURRENCY_CODE,
intendedTradeAmount,
false);
assertNotNull(trade);
assertEquals(offerId, trade.getTradeId());
@ -105,6 +107,7 @@ public class TakeBuyBSQOfferTest extends AbstractTradeTest {
genBtcBlocksThenWait(1, 2_500);
trade = bobClient.getTrade(tradeId);
assertEquals(intendedTradeAmount, trade.getTradeAmountAsLong());
verifyTakerDepositConfirmed(trade);
logTrade(log, testInfo, "Alice's Maker/Buyer View (Payment Sent)", aliceClient.getTrade(tradeId));
logTrade(log, testInfo, "Bob's Taker/Seller View (Payment Sent)", bobClient.getTrade(tradeId));

View File

@ -76,15 +76,18 @@ public class TakeBuyBTCOfferTest extends AbstractTradeTest {
assertEquals(1, alicesUsdOffers.size());
PaymentAccount bobsUsdAccount = createDummyF2FAccount(bobClient, "US");
var ignoredTakeOfferAmountParam = 0L;
var trade = takeAlicesOffer(offerId,
bobsUsdAccount.getId(),
TRADE_FEE_CURRENCY_CODE,
ignoredTakeOfferAmountParam,
false);
sleep(2_500); // Allow available offer to be removed from offer book.
alicesUsdOffers = aliceClient.getMyOffersSortedByDate(BUY.name(), USD);
assertEquals(0, alicesUsdOffers.size());
trade = bobClient.getTrade(tradeId);
assertEquals(alicesOffer.getAmount(), trade.getTradeAmountAsLong());
verifyTakerDepositNotConfirmed(trade);
logTrade(log, testInfo, "Alice's Maker/Buyer View", aliceClient.getTrade(tradeId));
logTrade(log, testInfo, "Bob's Taker/Seller View", bobClient.getTrade(tradeId));

View File

@ -37,6 +37,7 @@ package bisq.apitest.method.trade;
import bisq.core.payment.PaymentAccount;
import bisq.core.payment.payload.NationalBankAccountPayload;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import lombok.extern.slf4j.Slf4j;
@ -112,11 +113,12 @@ public class TakeBuyBTCOfferWithNationalBankAcctTest extends AbstractTradeTest {
var alicesOffers = aliceClient.getMyOffersSortedByDate(BUY.name(), BRL);
assertEquals(1, alicesOffers.size());
var trade = takeAlicesOffer(offerId,
bobsPaymentAccount.getId(),
TRADE_FEE_CURRENCY_CODE,
0L,
false);
assertEquals(alicesOffer.getAmount(), trade.getTradeAmountAsLong());
// Before generating a blk and confirming deposit tx, make sure there
// are no bank acct details in the either side's contract.
@ -130,13 +132,11 @@ public class TakeBuyBTCOfferWithNationalBankAcctTest extends AbstractTradeTest {
verifyJsonContractExcludesBankAccountDetails(bobsContract, bobsPaymentAccount);
break;
} catch (StatusRuntimeException ex) {
if (ex.getMessage() == null) {
if (ex.getStatus().equals(Status.NOT_FOUND)) {
String message = ex.getMessage().replaceFirst("^[A-Z_]+: ", "");
if (message.contains("trade") && message.contains("not found")) {
fail(ex);
}
log.warn(message);
} else {
sleep(500);
sleep(1_000);
}
}
}

View File

@ -87,11 +87,17 @@ public class TakeBuyXMROfferTest extends AbstractTradeTest {
var alicesXmrOffers = aliceClient.getMyOffers(btcTradeDirection, XMR);
assertEquals(1, alicesXmrOffers.size());
var trade = takeAlicesOffer(offerId, bobsXmrAcct.getId(), TRADE_FEE_CURRENCY_CODE);
var intendedTradeAmount = 10_000_000L;
var trade = takeAlicesOffer(offerId,
bobsXmrAcct.getId(),
TRADE_FEE_CURRENCY_CODE,
intendedTradeAmount);
alicesXmrOffers = aliceClient.getMyOffersSortedByDate(XMR);
assertEquals(0, alicesXmrOffers.size());
trade = bobClient.getTrade(tradeId);
assertEquals(intendedTradeAmount, trade.getTradeAmountAsLong());
verifyTakerDepositNotConfirmed(trade);
logTrade(log, testInfo, "Alice's Maker/Buyer View", aliceClient.getTrade(tradeId));
logTrade(log, testInfo, "Bob's Taker/Seller View", bobClient.getTrade(tradeId));

View File

@ -0,0 +1,108 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package bisq.apitest.method.trade;
import bisq.core.payment.PaymentAccount;
import bisq.proto.grpc.OfferInfo;
import io.grpc.StatusRuntimeException;
import org.bitcoinj.core.Coin;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
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.TestInfo;
import org.junit.jupiter.api.TestMethodOrder;
import static bisq.apitest.config.ApiTestConfig.BTC;
import static bisq.apitest.config.ApiTestConfig.USD;
import static java.lang.String.format;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;
import static protobuf.OfferDirection.BUY;
@Disabled
@SuppressWarnings("ConstantConditions")
@Slf4j
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class TakeOfferWithOutOfRangeAmountTest extends AbstractTradeTest {
@Test
@Order(1)
public void testTakeOfferWithInvalidAmountParam(final TestInfo testInfo) {
try {
PaymentAccount alicesUsdAccount = createDummyF2FAccount(aliceClient, "US");
PaymentAccount bobsUsdAccount = createDummyF2FAccount(bobClient, "US");
var alicesOffer = aliceClient.createMarketBasedPricedOffer(BUY.name(),
USD,
10_000_000L,
8_000_000L,
0.00,
defaultBuyerSecurityDepositPct.get(),
alicesUsdAccount.getId(),
BTC,
NO_TRIGGER_PRICE);
// Wait for Alice's AddToOfferBook task.
// Wait times vary; my logs show >= 2-second delay.
sleep(3_000); // TODO loop instead of hard code a wait time
List<OfferInfo> alicesUsdOffers = aliceClient.getMyOffersSortedByDate(BUY.name(), USD);
assertEquals(1, alicesUsdOffers.size());
var intendedTradeAmountTooLow = 7_000_000L;
takeOfferWithInvalidAmountParam(bobsUsdAccount, alicesOffer, intendedTradeAmountTooLow);
var intendedTradeAmountTooHigh = 11_000_000L;
takeOfferWithInvalidAmountParam(bobsUsdAccount, alicesOffer, intendedTradeAmountTooHigh);
} catch (StatusRuntimeException e) {
fail(e);
}
}
private void takeOfferWithInvalidAmountParam(PaymentAccount paymentAccount,
OfferInfo offer,
long invalidTakeOfferAmount) {
Throwable exception = assertThrows(StatusRuntimeException.class, () ->
takeAlicesOffer(offer.getId(),
paymentAccount.getId(),
BTC,
invalidTakeOfferAmount,
false));
var invalidAmount = Coin.valueOf(invalidTakeOfferAmount);
var minAmount = Coin.valueOf(offer.getMinAmount());
var maxAmount = Coin.valueOf(offer.getAmount());
String expectedExceptionMessage =
format("INVALID_ARGUMENT: intended trade amount %s is outside offer's min - max amount range of %s - %s",
invalidAmount.toPlainString(),
minAmount.toPlainString(),
maxAmount.toPlainString());
log.info(exception.getMessage());
assertEquals(expectedExceptionMessage, exception.getMessage());
}
}

View File

@ -87,16 +87,22 @@ public class TakeSellBSQOfferTest extends AbstractTradeTest {
assertTrue(alicesOffer.getIsCurrencyForMakerFeeBtc());
var alicesBsqOffers = aliceClient.getMyOffers(btcTradeDirection, BSQ);
assertEquals(1, alicesBsqOffers.size());
var intendedTradeAmount = 10_000_000L;
var trade = takeAlicesOffer(offerId,
bobsLegacyBsqAcct.getId(),
TRADE_FEE_CURRENCY_CODE,
intendedTradeAmount,
false);
sleep(2_500); // Allow available offer to be removed from offer book.
alicesBsqOffers = aliceClient.getMyOffersSortedByDate(BSQ);
assertEquals(0, alicesBsqOffers.size());
genBtcBlocksThenWait(1, 2_500);
waitForTakerDepositConfirmation(log, testInfo, bobClient, trade.getTradeId());
trade = bobClient.getTrade(tradeId);
assertEquals(intendedTradeAmount, trade.getTradeAmountAsLong());
verifyTakerDepositConfirmed(trade);
logTrade(log, testInfo, "Alice's Maker/Seller View", aliceClient.getTrade(tradeId));
logTrade(log, testInfo, "Bob's Taker/Buyer View", bobClient.getTrade(tradeId));

View File

@ -83,12 +83,14 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest {
var trade = takeAlicesOffer(offerId,
bobsUsdAccount.getId(),
TRADE_FEE_CURRENCY_CODE,
0L,
false);
sleep(2_500); // Allow available offer to be removed from offer book.
var takeableUsdOffers = bobClient.getOffersSortedByDate(SELL.name(), USD);
assertEquals(0, takeableUsdOffers.size());
trade = bobClient.getTrade(tradeId);
assertEquals(alicesOffer.getAmount(), trade.getTradeAmountAsLong());
verifyTakerDepositNotConfirmed(trade);
logTrade(log, testInfo, "Alice's Maker/Buyer View", aliceClient.getTrade(tradeId));
logTrade(log, testInfo, "Bob's Taker/Seller View", bobClient.getTrade(tradeId));

View File

@ -90,11 +90,17 @@ public class TakeSellXMROfferTest extends AbstractTradeTest {
var alicesXmrOffers = aliceClient.getMyOffers(btcTradeDirection, XMR);
assertEquals(1, alicesXmrOffers.size());
var trade = takeAlicesOffer(offerId, bobsXmrAcct.getId(), TRADE_FEE_CURRENCY_CODE);
var intendedTradeAmount = 10_500_000L;
var trade = takeAlicesOffer(offerId,
bobsXmrAcct.getId(),
TRADE_FEE_CURRENCY_CODE,
intendedTradeAmount);
alicesXmrOffers = aliceClient.getMyOffersSortedByDate(XMR);
assertEquals(0, alicesXmrOffers.size());
trade = bobClient.getTrade(tradeId);
assertEquals(intendedTradeAmount, trade.getTradeAmountAsLong());
verifyTakerDepositNotConfirmed(trade);
logTrade(log, testInfo, "Alice's Maker/Seller View", aliceClient.getTrade(tradeId));
logTrade(log, testInfo, "Bob's Taker/Buyer View", bobClient.getTrade(tradeId));

View File

@ -37,6 +37,7 @@ import bisq.apitest.method.trade.TakeBuyBSQOfferTest;
import bisq.apitest.method.trade.TakeBuyBTCOfferTest;
import bisq.apitest.method.trade.TakeBuyBTCOfferWithNationalBankAcctTest;
import bisq.apitest.method.trade.TakeBuyXMROfferTest;
import bisq.apitest.method.trade.TakeOfferWithOutOfRangeAmountTest;
import bisq.apitest.method.trade.TakeSellBSQOfferTest;
import bisq.apitest.method.trade.TakeSellBTCOfferTest;
import bisq.apitest.method.trade.TakeSellXMROfferTest;
@ -159,4 +160,11 @@ public class TradeTest extends AbstractTradeTest {
test.testFailAndUnFailBuyXmrTrade(testInfo);
test.testFailAndUnFailTakeSellXMRTrade(testInfo);
}
@Test
@Order(11)
public void testTakeOfferWithOutOfRangeAmount(final TestInfo testInfo) {
TakeOfferWithOutOfRangeAmountTest test = new TakeOfferWithOutOfRangeAmountTest();
test.testTakeOfferWithInvalidAmountParam(testInfo);
}
}

View File

@ -174,7 +174,7 @@ public class BotClient {
}
public TradeInfo takeOffer(String offerId, PaymentAccount paymentAccount, String feeCurrency) {
return grpcClient.takeOffer(offerId, paymentAccount.getId(), feeCurrency);
return grpcClient.takeOffer(offerId, paymentAccount.getId(), feeCurrency, 0L);
}
/**

View File

@ -80,6 +80,7 @@ import bisq.cli.opts.SendBtcOptionParser;
import bisq.cli.opts.SetTxFeeRateOptionParser;
import bisq.cli.opts.SetWalletPasswordOptionParser;
import bisq.cli.opts.SimpleMethodOptionParser;
import bisq.cli.opts.TakeBsqSwapOfferOptionParser;
import bisq.cli.opts.TakeOfferOptionParser;
import bisq.cli.opts.UnlockWalletOptionParser;
import bisq.cli.opts.VerifyBsqSentToAddressOptionParser;
@ -504,12 +505,15 @@ public class CliMain {
// 'takeoffer' request.
var offerCategory = client.getAvailableOfferCategory(offerId);
if (offerCategory.equals(BSQ_SWAP)) {
trade = client.takeBsqSwapOffer(offerId);
var opts = new TakeBsqSwapOfferOptionParser(args).parse();
var amount = toSatoshis(opts.getAmount());
trade = client.takeBsqSwapOffer(offerId, amount);
} else {
var opts = new TakeOfferOptionParser(args).parse();
var amount = toSatoshis(opts.getAmount());
var paymentAccountId = opts.getPaymentAccountId();
var takerFeeCurrencyCode = opts.getTakerFeeCurrencyCode();
trade = client.takeOffer(offerId, paymentAccountId, takerFeeCurrencyCode);
trade = client.takeOffer(offerId, paymentAccountId, takerFeeCurrencyCode, amount);
}
out.printf("trade %s successfully taken%n", trade.getTradeId());
return;
@ -912,6 +916,7 @@ public class CliMain {
stream.format(rowFormat, takeoffer.name(), "--offer-id=<offer-id> \\", "Take offer with id");
stream.format(rowFormat, "", "[--payment-account=<payment-account-id>]", "");
stream.format(rowFormat, "", "[--fee-currency=<btc|bsq>]", "");
stream.format(rowFormat, "", "[--amount=<min-btc-amount >= amount <= btc-amount>]", "");
stream.println();
stream.format(rowFormat, gettrade.name(), "--trade-id=<trade-id> \\", "Get trade summary or full contract");
stream.format(rowFormat, "", "[--show-contract=<true|false>]", "");

View File

@ -325,12 +325,18 @@ public final class GrpcClient {
return offersServiceRequest.getMyBsqSwapOffersSortedByDate();
}
public TradeInfo takeBsqSwapOffer(String offerId) {
return tradesServiceRequest.takeBsqSwapOffer(offerId);
public TradeInfo takeBsqSwapOffer(String offerId, long amount) {
return tradesServiceRequest.takeBsqSwapOffer(offerId, amount);
}
public TradeInfo takeOffer(String offerId, String paymentAccountId, String takerFeeCurrencyCode) {
return tradesServiceRequest.takeOffer(offerId, paymentAccountId, takerFeeCurrencyCode);
public TradeInfo takeOffer(String offerId,
String paymentAccountId,
String takerFeeCurrencyCode,
long amount) {
return tradesServiceRequest.takeOffer(offerId,
paymentAccountId,
takerFeeCurrencyCode,
amount);
}
public TradeInfo getTrade(String tradeId) {

View File

@ -0,0 +1,60 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package bisq.cli.opts;
import joptsimple.OptionSpec;
import static bisq.cli.CurrencyFormat.toSatoshis;
import static bisq.cli.opts.OptLabel.OPT_AMOUNT;
import static bisq.cli.opts.OptLabel.OPT_FEE_CURRENCY;
import static bisq.cli.opts.OptLabel.OPT_PAYMENT_ACCOUNT_ID;
public class TakeBsqSwapOfferOptionParser extends OfferIdOptionParser implements MethodOpts {
final OptionSpec<String> amountOpt = parser.accepts(OPT_AMOUNT, "intended amount of btc to buy or sell")
.withRequiredArg()
.defaultsTo("0");
public TakeBsqSwapOfferOptionParser(String[] args) {
super(args, true);
}
public TakeBsqSwapOfferOptionParser parse() {
super.parse();
// Super class will short-circuit parsing if help option is present.
if (options.has(amountOpt)) {
if (options.valueOf(amountOpt).isEmpty())
throw new IllegalArgumentException("no intended btc trade amount specified");
try {
toSatoshis(options.valueOf(amountOpt));
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("invalid amount: " + ex.getMessage());
}
}
return this;
}
public String getAmount() {
return options.valueOf(amountOpt);
}
}

View File

@ -20,11 +20,16 @@ package bisq.cli.opts;
import joptsimple.OptionSpec;
import static bisq.cli.CurrencyFormat.toSatoshis;
import static bisq.cli.opts.OptLabel.OPT_AMOUNT;
import static bisq.cli.opts.OptLabel.OPT_FEE_CURRENCY;
import static bisq.cli.opts.OptLabel.OPT_PAYMENT_ACCOUNT_ID;
public class TakeOfferOptionParser extends OfferIdOptionParser implements MethodOpts {
final OptionSpec<String> amountOpt = parser.accepts(OPT_AMOUNT, "intended amount of btc to buy or sell")
.withRequiredArg()
.defaultsTo("0");
final OptionSpec<String> paymentAccountIdOpt = parser.accepts(OPT_PAYMENT_ACCOUNT_ID, "id of payment account used for trade")
.withRequiredArg();
@ -41,12 +46,27 @@ public class TakeOfferOptionParser extends OfferIdOptionParser implements Method
// Super class will short-circuit parsing if help option is present.
if (options.has(amountOpt)) {
if (options.valueOf(amountOpt).isEmpty())
throw new IllegalArgumentException("no intended btc trade amount specified");
try {
toSatoshis(options.valueOf(amountOpt));
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("invalid amount: " + ex.getMessage());
}
}
if (!options.has(paymentAccountIdOpt) || options.valueOf(paymentAccountIdOpt).isEmpty())
throw new IllegalArgumentException("no payment account id specified");
return this;
}
public String getAmount() {
return options.valueOf(amountOpt);
}
public String getPaymentAccountId() {
return options.valueOf(paymentAccountIdOpt);
}

View File

@ -46,25 +46,38 @@ public class TradesServiceRequest {
this.grpcStubs = grpcStubs;
}
public TakeOfferReply getTakeOfferReply(String offerId, String paymentAccountId, String takerFeeCurrencyCode) {
public TakeOfferReply getTakeOfferReply(String offerId,
String paymentAccountId,
String takerFeeCurrencyCode,
long amount) {
var request = TakeOfferRequest.newBuilder()
.setOfferId(offerId)
.setPaymentAccountId(paymentAccountId)
.setTakerFeeCurrencyCode(takerFeeCurrencyCode)
.setAmount(amount)
.build();
return grpcStubs.tradesService.takeOffer(request);
}
public TradeInfo takeBsqSwapOffer(String offerId) {
var reply = getTakeOfferReply(offerId, "", "");
public TradeInfo takeBsqSwapOffer(String offerId, long amount) {
var reply = getTakeOfferReply(offerId,
"",
"",
amount);
if (reply.hasTrade())
return reply.getTrade();
else
throw new IllegalStateException(reply.getFailureReason().getDescription());
}
public TradeInfo takeOffer(String offerId, String paymentAccountId, String takerFeeCurrencyCode) {
var reply = getTakeOfferReply(offerId, paymentAccountId, takerFeeCurrencyCode);
public TradeInfo takeOffer(String offerId,
String paymentAccountId,
String takerFeeCurrencyCode,
long amount) {
var reply = getTakeOfferReply(offerId,
paymentAccountId,
takerFeeCurrencyCode,
amount);
if (reply.hasTrade())
return reply.getTrade();
else

View File

@ -2,10 +2,8 @@ package bisq.cli.opts;
import org.junit.jupiter.api.Test;
import static bisq.cli.Method.canceloffer;
import static bisq.cli.Method.createcryptopaymentacct;
import static bisq.cli.Method.createoffer;
import static bisq.cli.Method.createpaymentacct;
import static bisq.cli.Method.*;
import static bisq.cli.Method.takeoffer;
import static bisq.cli.opts.OptLabel.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ -383,4 +381,67 @@ public class OptionParsersTest {
assertEquals(currencyCode, parser.getCurrencyCode());
assertEquals(address, parser.getAddress());
}
// takeoffer opt parser tests
@Test
public void testTakeOfferForDefaultAmount() {
var offerId = "ABC-OFFER-ID";
var paymentAccountId = "ABC-ACCT-ID";
var takerFeeCurrencyCode = "BSQ";
var defaultAmount = "0";
String[] args = new String[]{
PASSWORD_OPT,
takeoffer.name(),
"--" + OPT_OFFER_ID + "=" + offerId,
"--" + OPT_PAYMENT_ACCOUNT_ID + "=" + paymentAccountId,
"--" + OPT_FEE_CURRENCY + "=" + takerFeeCurrencyCode
};
var parser = new TakeOfferOptionParser(args).parse();
assertEquals(offerId, parser.getOfferId());
assertEquals(paymentAccountId, parser.getPaymentAccountId());
assertEquals(takerFeeCurrencyCode, parser.getTakerFeeCurrencyCode());
assertEquals(defaultAmount, parser.getAmount());
}
@Test
public void testTakeOfferForNegativeAmount() {
var offerId = "ABC-OFFER-ID";
var paymentAccountId = "ABC-ACCT-ID";
var takerFeeCurrencyCode = "BSQ";
var amount = "-0.05";
String[] args = new String[]{
PASSWORD_OPT,
takeoffer.name(),
"--" + OPT_OFFER_ID + "=" + offerId,
"--" + OPT_PAYMENT_ACCOUNT_ID + "=" + paymentAccountId,
"--" + OPT_FEE_CURRENCY + "=" + takerFeeCurrencyCode,
"--" + OPT_AMOUNT + "=" + amount
};
Throwable exception = assertThrows(RuntimeException.class, () ->
new TakeOfferOptionParser(args).parse());
assertEquals("invalid amount: '-0.05' is not a positive number", exception.getMessage());
}
@Test
public void testTakeOffer() {
var offerId = "ABC-OFFER-ID";
var paymentAccountId = "ABC-ACCT-ID";
var takerFeeCurrencyCode = "BSQ";
var amount = "0.05";
String[] args = new String[]{
PASSWORD_OPT,
takeoffer.name(),
"--" + OPT_OFFER_ID + "=" + offerId,
"--" + OPT_PAYMENT_ACCOUNT_ID + "=" + paymentAccountId,
"--" + OPT_FEE_CURRENCY + "=" + takerFeeCurrencyCode,
"--" + OPT_AMOUNT + "=" + amount
};
var parser = new TakeOfferOptionParser(args).parse();
assertEquals(offerId, parser.getOfferId());
assertEquals(paymentAccountId, parser.getPaymentAccountId());
assertEquals(takerFeeCurrencyCode, parser.getTakerFeeCurrencyCode());
assertEquals(amount, parser.getAmount());
}
}

View File

@ -293,10 +293,12 @@ public class CoreApi {
///////////////////////////////////////////////////////////////////////////////////////////
public void takeBsqSwapOffer(String offerId,
long intendedTradeAmount,
TradeResultHandler<BsqSwapTrade> tradeResultHandler,
ErrorMessageHandler errorMessageHandler) {
Offer bsqSwapOffer = coreOffersService.getBsqSwapOffer(offerId);
coreTradesService.takeBsqSwapOffer(bsqSwapOffer,
intendedTradeAmount,
tradeResultHandler,
errorMessageHandler);
}
@ -304,12 +306,14 @@ public class CoreApi {
public void takeOffer(String offerId,
String paymentAccountId,
String takerFeeCurrencyCode,
long intendedTradeAmount,
Consumer<Trade> resultHandler,
ErrorMessageHandler errorMessageHandler) {
Offer offer = coreOffersService.getOffer(offerId);
coreTradesService.takeOffer(offer,
paymentAccountId,
takerFeeCurrencyCode,
intendedTradeAmount,
resultHandler,
errorMessageHandler);
}

View File

@ -114,15 +114,17 @@ class CoreTradesService {
this.user = user;
}
// TODO We need to pass the intended trade amount, not default to the maximum.
void takeBsqSwapOffer(Offer offer,
long intendedTradeAmount,
TradeResultHandler<BsqSwapTrade> tradeResultHandler,
ErrorMessageHandler errorMessageHandler) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
verifyIntendedTradeAmountIsInRange(intendedTradeAmount, offer);
bsqSwapTakeOfferModel.initWithData(offer);
bsqSwapTakeOfferModel.applyAmount(offer.getAmount());
bsqSwapTakeOfferModel.applyAmount(Coin.valueOf(intendedTradeAmount));
// Block attempt to take swap offer if there are insufficient funds for the trade.
var missingCoin = bsqSwapTakeOfferModel.getMissingFundsAsCoin();
@ -139,10 +141,10 @@ class CoreTradesService {
coreContext.isApiUser());
}
// TODO We need to pass the intended trade amount, not default to the maximum.
void takeOffer(Offer offer,
String paymentAccountId,
String takerFeeCurrencyCode,
long intendedTradeAmount,
Consumer<Trade> resultHandler,
ErrorMessageHandler errorMessageHandler) {
coreWalletsService.verifyWalletsAreAvailable();
@ -154,9 +156,11 @@ class CoreTradesService {
if (paymentAccount == null)
throw new IllegalArgumentException(format("payment account with id '%s' not found", paymentAccountId));
verifyIntendedTradeAmountIsInRange(intendedTradeAmount, offer);
var useSavingsWallet = true;
takeOfferModel.initModel(offer, paymentAccount, useSavingsWallet);
takeOfferModel.initModel(offer, paymentAccount, intendedTradeAmount, useSavingsWallet);
log.info("Initiating take {} offer, {}",
offer.isBuyOffer() ? "buy" : "sell",
takeOfferModel);
@ -167,7 +171,7 @@ class CoreTradesService {
format("wallet has insufficient btc to take offer with id '%s'", offer.getId()));
//noinspection ConstantConditions
tradeManager.onTakeOffer(offer.getAmount(),
tradeManager.onTakeOffer(Coin.valueOf(intendedTradeAmount),
takeOfferModel.getTxFeeFromFeeService(),
takeOfferModel.getTakerFee(),
takeOfferModel.isCurrencyForTakerFeeBtc(),
@ -457,4 +461,14 @@ class CoreTradesService {
String.join(", ", tradeIds)));
});
}
// Throws a RuntimeException if the takeoffer's amount parameter is out of range.
void verifyIntendedTradeAmountIsInRange(long intendedTradeAmount, Offer offer) {
if (intendedTradeAmount < offer.getMinAmount().value || intendedTradeAmount > offer.getAmount().value)
throw new IllegalArgumentException(
format("intended trade amount %s is outside offer's min - max amount range of %s - %s",
Coin.valueOf(intendedTradeAmount).toPlainString().toLowerCase(),
offer.getMinAmount().toPlainString().toLowerCase(),
offer.getAmount().toPlainString().toLowerCase()));
}
}

View File

@ -111,6 +111,7 @@ public class TakeOfferModel implements Model {
public void initModel(Offer offer,
PaymentAccount paymentAccount,
long intendedTradeAmount,
boolean useSavingsWallet) {
this.clearModel();
this.offer = offer;
@ -119,12 +120,12 @@ public class TakeOfferModel implements Model {
validateModelInputs();
this.useSavingsWallet = useSavingsWallet;
this.amount = valueOf(Math.min(offer.getAmount().value, getMaxTradeLimit()));
this.amount = valueOf(Math.min(intendedTradeAmount, getMaxTradeLimit()));
this.securityDeposit = offer.getDirection() == SELL
? offer.getBuyerSecurityDeposit()
: offer.getSellerSecurityDeposit();
this.isCurrencyForTakerFeeBtc = offerUtil.isCurrencyForTakerFeeBtc(amount);
this.takerFee = offerUtil.getTakerFee(isCurrencyForTakerFeeBtc, amount);
this.isCurrencyForTakerFeeBtc = offerUtil.isCurrencyForTakerFeeBtc(this.amount);
this.takerFee = offerUtil.getTakerFee(isCurrencyForTakerFeeBtc, this.amount);
calculateTxFees();
calculateVolume();

View File

@ -10,6 +10,7 @@ takeoffer
--offer-id=<offer-id>
--payment-account=<payment-acct-id>
[--fee-currency=<btc|bsq>]
[--amount=<offer.min-btc-amount >= amount <= offer.btc-amount>]
DESCRIPTION
-----------
@ -18,13 +19,13 @@ Take an existing offer. There are currently two types offers and trade protocols
BSQ swap offers
The takeoffer command only requires an offer-id parameter, and sufficient BSQ and BTC
to cover the trade amount and the taker fee. The trade (swap) will be executed immediately
after being successfully taken.
to cover the trade amount and the taker fee. The amount parameter is optional.
The trade (swap) will be executed immediately after being successfully taken.
Version 1 protocol fiat and BSQ offers
The offer-id and payment-account parameters are required. The fee-currency parameter can
be optionally used to pay the taker fee in BSQ.
The offer-id and payment-account parameters are required. The amount parameter is optional.
The fee-currency parameter can be optionally used to pay the taker fee in BSQ.
OPTIONS
-------
@ -38,14 +39,22 @@ OPTIONS
--fee-currency
The wallet currency used to pay the Bisq trade taker fee (BSQ|BTC). Default is BTC
--amount
The trade's intended btc amount. The amount must be within the offer's min-amount and (max) amount range.
If the taken offer's min-amount = amount, this request parameter must be equal the offer's amount (or omitted).
If this optional request parameter is omitted, the offers (max) amount is set on the new trade.
EXAMPLES
--------
To take a BSQ swap offer with ID y3a8b2e2-51b6-4f39-b6c1-3ebd52c22aea;
To take a BSQ swap offer with ID y3a8b2e2-51b6-4f39-b6c1-3ebd52c22aea,
setting the trade amount = the offer's amount (the amount parameter is omitted):
$ ./bisq-cli --password=xyz --port=9998 takeoffer --offer-id=y3a8b2e2-51b6-4f39-b6c1-3ebd52c22aea
To take an offer with ID 83e8b2e2-51b6-4f39-a748-3ebd29c22aea
using a payment account with ID fe20cdbd-22be-4b8a-a4b6-d2608ff09d6e,
and paying the Bisq trading fee in BSQ:
paying the Bisq trading fee in BSQ,
and setting the trade amount = the offer's min-amount (0.025 BTC):
$ ./bisq-cli --password=xyz --port=9998 takeoffer --offer-id=83e8b2e2-51b6-4f39-a748-3ebd29c22aea \
--payment-account=fe20cdbd-22be-4b8a-a4b6-d2608ff09d6e \
-fee-currency=bsq
-fee-currency=bsq \
--amount=0.025

View File

@ -97,9 +97,13 @@ class GrpcTradesService extends TradesImplBase {
try {
// Make sure the offer exists before trying to take it.
Offer offer = coreApi.getOffer(req.getOfferId());
var intendedTradeAmount = req.getAmount() == 0
? offer.getAmount().value
: req.getAmount();
if (offer.isBsqSwapOffer()) {
coreApi.takeBsqSwapOffer(offer.getId(),
intendedTradeAmount,
bsqSwapTrade -> {
var reply = buildTakeOfferReply(bsqSwapTrade);
responseObserver.onNext(reply);
@ -113,6 +117,7 @@ class GrpcTradesService extends TradesImplBase {
coreApi.takeOffer(offer.getId(),
req.getPaymentAccountId(),
req.getTakerFeeCurrencyCode(),
intendedTradeAmount,
trade -> {
var reply = buildTakeOfferReply(trade);
responseObserver.onNext(reply);

View File

@ -516,6 +516,10 @@ message TakeOfferRequest {
string offer_id = 1; // The unique identifier of the offer being taken.
string payment_account_id = 2; // The unique identifier of the payment account used to take offer.
string taker_fee_currency_code = 3; // The code of the currency (BSQ or BTC) used to pay the taker's Bisq trade fee.
// The trade's intended BTC amount in satoshis. Ten million satoshis is represented as 10000000.
// If set, the takeoffer amount value must be >= offer.min_amount and <= offer.amount.
// If not set (0 default), the taken offer's (max) amount becomes the intended trade amount.
uint64 amount = 4;
}
message TakeOfferReply {