-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
Refactor fee estimation #2251
Changes from 6 commits
d876cc1
76dd6b5
274beef
ba114cb
d09296e
28d0db8
645719c
461dd1d
877d0ae
ed8df87
250c7c1
64f65e0
90e95e5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
} | ||
|
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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... There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 😉 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} | ||
} |
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no need for PowerMock in this test class. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is separate test scenario and should be placed in separate method. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ;-) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
// 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do/while loop inside
And for the second iteration we have to mock with
Alternatively we can mock
|
||
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); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we avoid magic numbers? https://en.wikipedia.org/wiki/Magic_number_(programming)
There was a problem hiding this comment.
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.