-
Notifications
You must be signed in to change notification settings - Fork 847
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
fix: apply Math.Round to FeeRate to mitigate rounding issues #1189
base: master
Are you sure you want to change the base?
Conversation
@@ -56,7 +56,7 @@ public FeeRate(Money feePaid, int size) | |||
if (feePaid.Satoshi < 0) | |||
throw new ArgumentOutOfRangeException(nameof(feePaid), "Cannot be less than 0."); | |||
if (size > 0) | |||
_FeePerK = (long)((decimal)feePaid.Satoshi / (decimal)size * 1000m); | |||
_FeePerK = (long)Math.Round((decimal)feePaid.Satoshi / size * 1000m, MidpointRounding.AwayFromZero); |
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.
@lukasdll just curious, if the LOC said _FeePerK = (long)((decimal)feePaid.Satoshi / (decimal)(size * 1000));
instead of _FeePerK = (long)((decimal)feePaid.Satoshi / (decimal)size * 1000m);
, wouldn't the automatic rounding done by the cast been better? I mean, your fix is probably better than what I just said, but just thinking that the cast being done after the multiplication would have been better before
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.
nevermind, I was looking at the wrong cast (I guess it's the (long)
cast that is problematic, not the (decimal)
one)
I think the real problem is that the fee rate is represented internally as a |
So why not fix this root cause instead of improving the rounding? |
Thanks for the input! 👍 If I understood correctly, there are two main options. Option 1: Changing the _FeePerK type from Money to decimal. This would bring more precision but requires extensive code review and testing to ensure backward compatibility. It might also come at a cost as it increases resource usage due to decimal operations being more costly than long operations. Option 2: Adopting a larger multiplier, say per MB instead of KB (i.e., using 1,000,000) - this would increase the precision to a more than sufficient level without a major refactor. Keen to hear more thoughts! |
Those were not the two options. The fee rate is a rate, a fractional number. No trick or data type (other than a Fractional data type, which is not built in the BCL) can represent a rate without losing precision. The |
I was thinking a bit more about this issue and given we never operate on fee rates then it makes little sense how we represent it internally. I mean, this class is not designed to be used to do math with fee rates, it was designed to simply express a datum and that's it so, using fractions would be overkilling. Using a decimal should be enough. |
Indeed, the second option I pictured above would be an improvement of a fix, from the fee rate/kilo to fee rate/mega to earn in precision. While it might work and be simpler to implement, using the decimal type internally would be more elegant, and I agree is a more pertinent choice. Strictly speaking, from a rounding perspective, the difference narrows down to 6 digits in the case of the fee rate / mega, compared to the 29 digits from the decimal type. I have the intuition that 6 digits would be sufficient, because I don't think that anyone realistically need to express a fee rate with more than 6 decimals, however, the code would be easier to understand using decimals than the fee rate/kilo, or fee rate/mega, therefore, if there are no drawbacks from the additional computational cost handling 29 decimals implies, I convey (also from a practical point of view) that using decimals is the way to go. Using decimals could also eliminate a few math calcs, improving the result from a design perspective. If it's not possible to completely eliminate all mathematical operations from the FeeRate class, we could deprecate the related method(s) and move the calculations to a helper class to keep the FeeRate class strictly for data representation. I would have to dig deeper into NBitcoin to determine whether this aligns with the rest of the code or if there is already some sort of helper class that could be utilized for that purpose An additional minor detail. The null checks could also be factored into a helper method (it is repeated 6 times in that class):
I'm going to do some changes & tests to cover the above. I will try the decimal type approach, and try to remove math calcs as much as possible, or suggest changes as appropriate. |
@lukasdll can you add a small unit test? |
Problem:
The FeeRate constructor FeeRate(Money feePaid, int size) might lead to minor rounding issues due to direct casting to long, especially when feePaid.Satoshi/size has a fractional part >= 0.5.
This issue manifest in other projects using NBitcoin, in particular: WalletWasabi/WalletWasabi#11215
E.g.:
The fee rate per byte would be: 101/70 = 1.442857...
The fee rate per kilobyte would be: 1.442857 * 1000 = 1442.857...
But, when converted to long, the value becomes 1442, effectively rounding down and losing the 0.857 fractional value.
Solution:
Implemented Math.Round to handle rounding more predictably and prevent potential issues with transaction fee calculation precision.
By using Math.Round with MidpointRounding.AwayFromZero, any decimal value from .5 to .999' is always rounded up to the next whole number. Without specifying MidpointRounding.AwayFromZero, and thus using the default MidpointRounding.ToEven (Banker’s Rounding), values ending in .5 are rounded to the nearest even whole number, which may produce discrepancies or undesired results in certain circumstances.
This fix ensures that fee rate calculations are more accurate and prevents underpaying or overpaying fees due to rounding discrepancies.