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);
}
}

179 changes: 179 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,179 @@
/*
* 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 static int DEPOSIT_TX_SIZE = 320;
private static int PAYOUT_TX_SIZE = 380;
private static int BSQ_INPUT_INCREASE = 150;
private static int MAX_ITERATIONS = 10;
ManfredKarrer marked this conversation as resolved.
Show resolved Hide resolved

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 = TYPICAL_TX_WITH_1_INPUT_SIZE;
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 = PAYOUT_TX_SIZE;
}
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
// TODO: Clarify if there is always just one additional input or if there can be more.
estimatedTxSize += BSQ_INPUT_INCREASE;
}

Coin txFee;
int size;
if (isTaker) {
int averageSize = (estimatedTxSize + DEPOSIT_TX_SIZE) / 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(PAYOUT_TX_SIZE, 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);
}

// We start with the initialEstimatedTxSize for a tx with 1 input (260) bytes and get from BitcoinJ a tx back which
// contains the required inputs to fund that tx (out outputs + miner fee). The miner fee is that case is based on
// the assumption that we only need 1 input. Once we receive back the real tx size from the tx BitcoinJ has created
// with the required inputs we compare that if it is in the tolerance of 20% from our assumed tx size. If we inside
// that tolerance we use that tx size for our fee estimation. If not (if there has been more then 1 inputs) we
// apply the new fee based on the reported tx size and request again from BitcoinJ to full that tx with the inputs
// to be sufficiently funded. The algorithm how BitcoinJ selects utxos is complex and contains several aspects
// (minimize fee, don't create too many tiny utxos,...). We treat that algorithm as an unknown and it is not
// guaranteed that there are more inputs required if we increase the fee (it could be that there is a better
// selection of inputs chosen if we have increased the fee and therefor less inputs). As the increased fee might
// change the number of inputs we need to repeat that process until we are inside of a certain tolerance. To avoid
// potential endless loops we add a counter. Worst case would be that the last size we got reported is > 20% off to
// the real tx size but as fee estimation is anyway a educated guess in the best case we don't worry too much.
// If we have underpaid the tx might take longer to get confirmed.
@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 < MAX_ITERATIONS);
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
146 changes: 146 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,146 @@
/*
* 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.junit.Ignore;
import org.junit.Test;

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;

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

@Test
public void testGetEstimatedTxSize_withDefaultTxSize() 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);
}

// FIXME @Bernard could you have a look?
@Test
@Ignore
public void testGetEstimatedTxSize_withLargeTx() 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 = 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);


// repeated calls to getEstimatedFeeTxSize do not work (returns 0 at second call in loop which cause test to fail)
result = TxFeeEstimationService.getEstimatedTxSize(outputValues, initialEstimatedTxSize, txFeePerByte, btcWalletService);
assertEquals(2600, result);
}

// FIXME @Bernard could you have a look?
Copy link

Choose a reason for hiding this comment

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

Same as above

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 Thanks. I would prefer to leave that for another PR, too buys with other high prio stuff. If you have time to take that would be great as well!

@Test
@Ignore
public void testGetEstimatedTxSize_withSmallTx() 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 = 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