Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor fee estimation #2251

Merged
1 change: 1 addition & 0 deletions core/src/main/java/bisq/core/btc/BitcoinModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ protected void configure() {
bind(FeeProvider.class).in(Singleton.class);
bind(PriceFeedService.class).in(Singleton.class);
bind(FeeService.class).in(Singleton.class);
bind(TxFeeEstimationService.class).in(Singleton.class);
}
}

159 changes: 159 additions & 0 deletions core/src/main/java/bisq/core/btc/TxFeeEstimationService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* 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.core.btc;

import bisq.core.btc.wallet.BtcWalletService;
import bisq.core.provider.fee.FeeService;
import bisq.core.user.Preferences;

import bisq.common.util.Tuple2;

import org.bitcoinj.core.Coin;
import org.bitcoinj.core.InsufficientMoneyException;

import javax.inject.Inject;

import com.google.common.annotations.VisibleForTesting;

import java.util.List;

import lombok.extern.slf4j.Slf4j;

import static com.google.common.base.Preconditions.checkArgument;

/**
* Util class for getting the estimated tx fee for maker or taker fee tx.
*/
@Slf4j
public class TxFeeEstimationService {
public static int TYPICAL_TX_WITH_1_INPUT_SIZE = 260;
private final FeeService feeService;
private final BtcWalletService btcWalletService;
private final Preferences preferences;

@Inject
public TxFeeEstimationService(FeeService feeService,
BtcWalletService btcWalletService,
Preferences preferences) {

this.feeService = feeService;
this.btcWalletService = btcWalletService;
this.preferences = preferences;
}

public Tuple2<Coin, Integer> getEstimatedFeeAndTxSizeForTaker(Coin fundsNeededForTrade, Coin tradeFee) {
return getEstimatedFeeAndTxSize(true,
fundsNeededForTrade,
tradeFee,
feeService,
btcWalletService,
preferences);
}

public Tuple2<Coin, Integer> getEstimatedFeeAndTxSizeForMaker(Coin reservedFundsForOffer,
Coin tradeFee) {
return getEstimatedFeeAndTxSize(false,
reservedFundsForOffer,
tradeFee,
feeService,
btcWalletService,
preferences);
}

private Tuple2<Coin, Integer> getEstimatedFeeAndTxSize(boolean isTaker,
Coin amount,
Coin tradeFee,
FeeService feeService,
BtcWalletService btcWalletService,
Preferences preferences) {
Coin txFeePerByte = feeService.getTxFeePerByte();
// We start with min taker fee size of 260
int estimatedTxSize = 260;
try {
estimatedTxSize = getEstimatedTxSize(List.of(tradeFee, amount), estimatedTxSize, txFeePerByte, btcWalletService);
} catch (InsufficientMoneyException e) {
if (isTaker) {
// if we cannot do the estimation we use the payout tx size
estimatedTxSize = 380;
}
log.info("We cannot do the fee estimation because there are not enough funds in the wallet. This is expected " +
"if the user pays from an external wallet. In that case we use an estimated tx size of {} bytes.", estimatedTxSize);
}

if (!preferences.isPayFeeInBtc()) {
// If we pay the fee in BSQ we have one input more which adds about 150 bytes
estimatedTxSize += 150;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we can use a constant. It is just used once so thats why I did not use one and the class is small so easty to overlook all as well there is a comment decribing the value. But no problem to make a constant for it.

}

Coin txFee;
int size;
if (isTaker) {
int averageSize = (estimatedTxSize + 320) / 2; // deposit tx has about 320 bytes
// We use at least the size of the payout tx to not underpay at payout.
size = Math.max(380, averageSize);
txFee = txFeePerByte.multiply(size);
log.info("Fee estimation resulted in a tx size of {} bytes.\n" +
"We use an average between the taker fee tx and the deposit tx (320 bytes) which results in {} bytes.\n" +
"The payout tx has 380 bytes, we use that as our min value. Size for fee calculation is {} bytes.\n" +
"The tx fee of {} Sat", estimatedTxSize, averageSize, size, txFee.value);
} else {
size = estimatedTxSize;
txFee = txFeePerByte.multiply(size);
log.info("Fee estimation resulted in a tx size of {} bytes and a tx fee of {} Sat.", size, txFee.value);
}

return new Tuple2<>(txFee, size);
}

@VisibleForTesting
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been always told that this is a bad idea, as it's an attempt to test implementation details.
Either extract it to an utility class that could be tested separately or do not test it individually.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not clear what you mean. Lets discuss on a call in a bit...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @blabno is talking about the fact, that if you need to make something visible for testing that wouldn't be accessible otherwise, is a sign that something should be structured or tested differently 😉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok. Yes basically agree. In that case I would not like to expose getEstimatedTxSize as it is only interesting for that class. The more high level methods carry much more special case handling that they would be harder to test then the core functionality in getEstimatedTxSize.

static int getEstimatedTxSize(List<Coin> outputValues,
int initialEstimatedTxSize,
Coin txFeePerByte,
BtcWalletService btcWalletService)
throws InsufficientMoneyException {
boolean isInTolerance;
int estimatedTxSize = initialEstimatedTxSize;
int realTxSize;
int counter = 0;
do {
ManfredKarrer marked this conversation as resolved.
Show resolved Hide resolved
Coin txFee = txFeePerByte.multiply(estimatedTxSize);
realTxSize = btcWalletService.getEstimatedFeeTxSize(outputValues, txFee);
isInTolerance = isInTolerance(estimatedTxSize, realTxSize, 0.2);
if (!isInTolerance) {
estimatedTxSize = realTxSize;
}
counter++;
}
while (!isInTolerance && counter < 10);
if (!isInTolerance) {
log.warn("We could not find a tx which satisfies our tolerance requirement of 20%. " +
"realTxSize={}, estimatedTxSize={}",
realTxSize, estimatedTxSize);
}
return estimatedTxSize;
}

@VisibleForTesting
static boolean isInTolerance(int estimatedSize, int txSize, double tolerance) {
checkArgument(estimatedSize > 0, "estimatedSize must be positive");
checkArgument(txSize > 0, "txSize must be positive");
checkArgument(tolerance > 0, "tolerance must be positive");
double deviation = Math.abs(1 - ((double) estimatedSize / (double) txSize));
return deviation <= tolerance;
}
}
18 changes: 18 additions & 0 deletions core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,24 @@ private boolean feeEstimationNotSatisfied(int counter, Transaction tx) {
tx.getFee().value - targetFee > 1000);
}

public int getEstimatedFeeTxSize(List<Coin> outputValues, Coin txFee)
throws InsufficientMoneyException, AddressFormatException {
Transaction transaction = new Transaction(params);
Address dummyAddress = wallet.currentReceiveKey().toAddress(params);
outputValues.forEach(outputValue -> transaction.addOutput(outputValue, dummyAddress));

SendRequest sendRequest = SendRequest.forTx(transaction);
sendRequest.shuffleOutputs = false;
sendRequest.aesKey = aesKey;
sendRequest.coinSelector = new BtcCoinSelector(walletsSetup.getAddressesByContext(AddressEntry.Context.AVAILABLE));
sendRequest.fee = txFee;
sendRequest.feePerKb = Coin.ZERO;
sendRequest.ensureMinRequiredFee = false;
sendRequest.changeAddress = dummyAddress;
wallet.completeTx(sendRequest);
return transaction.bitcoinSerialize().length;
}


///////////////////////////////////////////////////////////////////////////////////////////
// Withdrawal Send
Expand Down
31 changes: 0 additions & 31 deletions core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java
Original file line number Diff line number Diff line change
Expand Up @@ -230,37 +230,6 @@ public Transaction createBtcTradingFeeTx(Address fundingAddress,
}
}

public Transaction estimateBtcTradingFeeTxSize(Address fundingAddress,
Address reservedForTradeAddress,
Address changeAddress,
Coin reservedFundsForOffer,
boolean useSavingsWallet,
Coin tradingFee,
Coin txFee,
String feeReceiverAddresses)
throws InsufficientMoneyException, AddressFormatException {
Transaction tradingFeeTx = new Transaction(params);
tradingFeeTx.addOutput(tradingFee, Address.fromBase58(params, feeReceiverAddresses));
tradingFeeTx.addOutput(reservedFundsForOffer, reservedForTradeAddress);

SendRequest sendRequest = SendRequest.forTx(tradingFeeTx);
sendRequest.shuffleOutputs = false;
sendRequest.aesKey = aesKey;
if (useSavingsWallet)
sendRequest.coinSelector = new BtcCoinSelector(walletsSetup.getAddressesByContext(AddressEntry.Context.AVAILABLE));
else
sendRequest.coinSelector = new BtcCoinSelector(fundingAddress);

sendRequest.fee = txFee;
sendRequest.feePerKb = Coin.ZERO;
sendRequest.ensureMinRequiredFee = false;
sendRequest.changeAddress = changeAddress;
checkNotNull(wallet, "Wallet must not be null");
log.info("estimateBtcTradingFeeTxSize");
wallet.completeTx(sendRequest);
return tradingFeeTx;
}

public Transaction completeBsqTradingFeeTx(Transaction preparedBsqTx,
Address fundingAddress,
Address reservedForTradeAddress,
Expand Down
123 changes: 123 additions & 0 deletions core/src/test/java/bisq/core/btc/TxFeeEstimationServiceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* 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.core.btc;

import bisq.core.btc.wallet.BtcWalletService;

import org.bitcoinj.core.Coin;
import org.bitcoinj.core.InsufficientMoneyException;

import java.util.List;

import org.powermock.core.classloader.annotations.PowerMockIgnore;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;

import org.junit.Test;
import org.junit.runner.RunWith;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

@RunWith(PowerMockRunner.class)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need for PowerMock in this test class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I will remove it

@PrepareForTest(BtcWalletService.class)
@PowerMockIgnore({"com.sun.org.apache.xerces.*", "javax.xml.*", "org.xml.*"})
public class TxFeeEstimationServiceTest {

@Test
public void testGetEstimatedTxSize() throws InsufficientMoneyException {
List<Coin> outputValues = List.of(Coin.valueOf(2000), Coin.valueOf(3000));
int initialEstimatedTxSize;
Coin txFeePerByte;
BtcWalletService btcWalletService = mock(BtcWalletService.class);
int result;
int realTxSize;
Coin txFee;

initialEstimatedTxSize = 260;
txFeePerByte = Coin.valueOf(10);
realTxSize = 260;

txFee = txFeePerByte.multiply(initialEstimatedTxSize);
when(btcWalletService.getEstimatedFeeTxSize(outputValues, txFee)).thenReturn(realTxSize);
result = TxFeeEstimationService.getEstimatedTxSize(outputValues, initialEstimatedTxSize, txFeePerByte, btcWalletService);
assertEquals(260, result);


// TODO check how to use the mocking framework for repeated calls
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is separate test scenario and should be placed in separate method.
Good naming convention for test methods is testMethodName_scenario_expectedCondition
i.e. getEstimatedTxSize_nullOutputs_throwsException

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You refer to the commented out lines, right? Would you mind to have a hands on the test class? More your domain, you know my love for tests is limited ;-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@blabno I broke them up in 877d0ae and looked again into it. See commit comments... Could u have a look to try to fix it?

// The btcWalletService.getEstimatedFeeTxSize returns 0 at repeated calls in the while loop....
/* initialEstimatedTxSize = 260;
txFeePerByte = Coin.valueOf(10);
realTxSize = 2600;

txFee = txFeePerByte.multiply(initialEstimatedTxSize);
when(btcWalletService.getEstimatedFeeTxSize(outputValues, txFee)).thenReturn(realTxSize);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do/while loop inside getEstimatedFeeTxSize has 2 cycles because first invocation of isInTolerance returns false.
So for the first iteration we have to mock with initial txFee:

when(btcWalletService.getEstimatedFeeTxSize(outputValues, txFee)).thenReturn(realTxSize);

And for the second iteration we have to mock with txFee multiplied by txFeePerByte.

when(btcWalletService.getEstimatedFeeTxSize(outputValues, txFeePerByte.multiply(realTxSize))).thenReturn(realTxSize)

Alternatively we can mock getEstimatedFeeTxSize to return the same result for any txFee:

when(btcWalletService.getEstimatedFeeTxSize(eq(outputValues), any(Coin.class))).thenReturn(realTxSize);

result = TxFeeEstimationService.getEstimatedTxSize(outputValues, initialEstimatedTxSize, txFeePerByte, btcWalletService);
assertEquals(2600, result);

initialEstimatedTxSize = 2600;
txFeePerByte = Coin.valueOf(10);
realTxSize = 260;

txFee = txFeePerByte.multiply(initialEstimatedTxSize);
when(btcWalletService.getEstimatedFeeTxSize(outputValues, txFee)).thenReturn(realTxSize);
result = TxFeeEstimationService.getEstimatedTxSize(outputValues, initialEstimatedTxSize, txFeePerByte, btcWalletService);
assertEquals(260, result);*/
}

@Test
public void testIsInTolerance() {
int estimatedSize;
int txSize;
double tolerance;
boolean result;

estimatedSize = 100;
txSize = 100;
tolerance = 0.0001;
result = TxFeeEstimationService.isInTolerance(estimatedSize, txSize, tolerance);
assertTrue(result);

estimatedSize = 100;
txSize = 200;
tolerance = 0.2;
result = TxFeeEstimationService.isInTolerance(estimatedSize, txSize, tolerance);
assertFalse(result);

estimatedSize = 120;
txSize = 100;
tolerance = 0.2;
result = TxFeeEstimationService.isInTolerance(estimatedSize, txSize, tolerance);
assertTrue(result);

estimatedSize = 200;
txSize = 100;
tolerance = 1;
result = TxFeeEstimationService.isInTolerance(estimatedSize, txSize, tolerance);
assertTrue(result);

estimatedSize = 201;
txSize = 100;
tolerance = 1;
result = TxFeeEstimationService.isInTolerance(estimatedSize, txSize, tolerance);
assertFalse(result);
}
}
Loading