Block API takeoffer attempt if !sufficient btc in wallet

This API bug was relying on offer availability checks, but those do
not check the taker's wallet.  The take offer model makes the check convenient,
and a core.api NotAvailableException can be thrown from CoreTradesService,
then mapped to the appropriate gPRC UNAVAILABLE exception sent to clients.

A new test case is added for this change:  Bob's wallet is emptied, he
fails to take an offer, and Alice returns Bob's BTC.
This commit is contained in:
ghubstan 2022-05-22 17:00:33 -03:00
parent 0ced87cd22
commit c9f3aa9edf
No known key found for this signature in database
GPG Key ID: E35592D6800A861E
2 changed files with 162 additions and 2 deletions

View File

@ -0,0 +1,153 @@
/*
* 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.BtcBalanceInfo;
import bisq.proto.grpc.OfferInfo;
import io.grpc.StatusRuntimeException;
import java.util.List;
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.TestMethodOrder;
import static bisq.apitest.config.ApiTestConfig.BSQ;
import static bisq.apitest.config.ApiTestConfig.USD;
import static bisq.cli.CurrencyFormat.formatBtc;
import static bisq.cli.table.builder.TableType.BTC_BALANCE_TBL;
import static java.lang.Math.abs;
import static java.lang.String.format;
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.fail;
import static protobuf.OfferDirection.BUY;
import bisq.cli.table.builder.TableBuilder;
/**
* This test should not be @Disabled, nor run from the scenario package's TradeTest suite.
* The risk of causing all test suites to fail due to insufficient funds is too great,
* as of 22-May-2022.
*/
@SuppressWarnings("ConstantConditions")
@Slf4j
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class InsufficientBtcToTakeOfferTest extends AbstractTradeTest {
private static final String TRADE_FEE_CURRENCY_CODE = BSQ;
// Bob's BTC wallet is nearly emptied in the test case: most is sent to Alice,
// then Bob tries to take an offer, resulting in a NotAvailableException.
// Alice returns the exchanged BTC at the end of the test.
private String sendAmount = "0";
@Test
@Order(1)
public void test1() {
try {
PaymentAccount alicesUsdAccount = createDummyF2FAccount(aliceClient, "US");
PaymentAccount bobsUsdAccount = createDummyF2FAccount(bobClient, "US");
// Empty Bob's BTC wallet; send almost all of it to Alice.
long bobsAvailableSats = bobClient.getBtcBalances().getAvailableBalance();
long satsToLeaveInBobsWallet = 2000000;
long statsToSendToAlice = abs(satsToLeaveInBobsWallet - bobsAvailableSats);
sendAmount = formatBtc(statsToSendToAlice);
String aliceAddress = aliceClient.getUnusedBtcAddress();
bobClient.sendBtc(aliceAddress, sendAmount, "", "");
genBtcBlocksThenWait(1, 2_500);
showBalances("after emptying Bob's BTC wallet");
var alicesOffer = aliceClient.createMarketBasedPricedOffer(BUY.name(),
USD,
12_500_000L,
12_500_000L, // min-amount = amount
0.00,
defaultBuyerSecurityDepositPct.get(),
alicesUsdAccount.getId(),
TRADE_FEE_CURRENCY_CODE,
NO_TRIGGER_PRICE);
var offerId = alicesOffer.getId();
assertFalse(alicesOffer.getIsCurrencyForMakerFeeBtc());
// 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());
// Try to take the offer 5x, fail each time, assert offer remains available.
for (int i = 0; i < 5; i++) {
Throwable exception = assertThrows(StatusRuntimeException.class, () ->
takeAlicesOffer(offerId,
bobsUsdAccount.getId(),
TRADE_FEE_CURRENCY_CODE,
false));
String expectedExceptionMessage =
format("UNAVAILABLE: wallet has insufficient btc to take offer with id '%s'", offerId);
log.debug(exception.getMessage());
assertEquals(expectedExceptionMessage, exception.getMessage());
// Alice's offer can still be looked up by Alice.
alicesUsdOffers = aliceClient.getMyOffersSortedByDate(BUY.name(), USD);
assertEquals(1, alicesUsdOffers.size());
// Offer should still be available to Bob.
var availableOffer = bobClient.getOffer(offerId);
log.debug("Offer still available:\n{}", toOfferTable.apply(availableOffer));
sleep(3_000);
}
} catch (StatusRuntimeException e) {
fail(e);
}
showBalances("after failed take offer attempts");
// Send Bob's BTC back to him.
String bobsAddress = bobClient.getUnusedBtcAddress();
aliceClient.sendBtc(bobsAddress, sendAmount, "", "");
genBtcBlocksThenWait(1, 2_500);
showBalances("after returning Bob's BTC");
}
private void showBalances(String msg) {
if (log.isDebugEnabled()) {
BtcBalanceInfo alicesBalances = aliceClient.getBtcBalances();
log.debug("Alice's BTC Balances {}:\n{}",
msg,
new TableBuilder(BTC_BALANCE_TBL, alicesBalances).build());
BtcBalanceInfo bobsBalances = bobClient.getBtcBalances();
log.debug("Bob's BTC Balances {}:\n{}",
msg,
new TableBuilder(BTC_BALANCE_TBL, bobsBalances).build());
}
}
}

View File

@ -17,6 +17,7 @@
package bisq.core.api;
import bisq.core.api.exception.NotAvailableException;
import bisq.core.api.exception.NotFoundException;
import bisq.core.btc.model.AddressEntry;
import bisq.core.btc.wallet.BtcWalletService;
@ -50,6 +51,7 @@ import org.bitcoinj.core.Coin;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
@ -151,6 +153,11 @@ class CoreTradesService {
log.info("Initiating take {} offer, {}",
offer.isBuyOffer() ? "buy" : "sell",
takeOfferModel);
if (!takeOfferModel.isBtcWalletFunded())
throw new NotAvailableException(
format("wallet has insufficient btc to take offer with id '%s'", offer.getId()));
//noinspection ConstantConditions
tradeManager.onTakeOffer(offer.getAmount(),
takeOfferModel.getTxFeeFromFeeService(),
@ -293,7 +300,7 @@ class CoreTradesService {
List<TradeModel> getOpenTrades() {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
return tradeManager.getTrades().stream().collect(Collectors.toList());
return new ArrayList<>(tradeManager.getTrades());
}
List<TradeModel> getTradeHistory(GetTradesRequest.Category category) {
@ -307,7 +314,7 @@ class CoreTradesService {
return closedTrades;
} else {
var failedV1Trades = failedTradesManager.getTrades();
return failedV1Trades.stream().collect(Collectors.toList());
return new ArrayList<>(failedV1Trades);
}
}