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

feat!: convert fees from BTC/kB to sats/vB #136

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

storopoli
Copy link

Copy link
Contributor

@tnull tnull left a comment

Choose a reason for hiding this comment

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

Hmm, see my comment over on the issue, but why not have estimate_fee/batch_estimate_fee return bitcoin::FeeRates?

Otherwise this would amount to a silent change of the API semantics that could have severe consequences for users that expect to get a fee rate in a certain denomination and now suddenly we'd return a different one.

@storopoli
Copy link
Author

I was just emulating what we already do in rust-esplora-client:

/// Get a fee value in sats/vbytes from the estimates
/// that matches the confirmation target set as parameter.
pub fn convert_fee_rate(target: usize, estimates: HashMap<u16, f64>) -> Result<f32, Error> {
    let fee_val = {
        let mut pairs = estimates.into_iter().collect::<Vec<(u16, f64)>>();
        pairs.sort_unstable_by_key(|(k, _)| std::cmp::Reverse(*k));
        pairs
            .into_iter()
            .find(|(k, _)| *k as usize <= target)
            .map(|(_, v)| v)
            .unwrap_or(1.0)
    };
    Ok(fee_val as f32)
}

Shall we use FeeRate instead?

@tnull
Copy link
Contributor

tnull commented Jul 24, 2024

I was just emulating what we already do in rust-esplora-client:

/// Get a fee value in sats/vbytes from the estimates
/// that matches the confirmation target set as parameter.
pub fn convert_fee_rate(target: usize, estimates: HashMap<u16, f64>) -> Result<f32, Error> {
    let fee_val = {
        let mut pairs = estimates.into_iter().collect::<Vec<(u16, f64)>>();
        pairs.sort_unstable_by_key(|(k, _)| std::cmp::Reverse(*k));
        pairs
            .into_iter()
            .find(|(k, _)| *k as usize <= target)
            .map(|(_, v)| v)
            .unwrap_or(1.0)
    };
    Ok(fee_val as f32)
}

Shall we use FeeRate instead?

I think it would be preferable, yes. At least a lot less error-prone and unambiguous than using a f32 or similar.

@storopoli
Copy link
Author

I agree and like the idea. Will update the PR soon. Thanks!

@storopoli storopoli requested a review from tnull July 24, 2024 17:09
@matthiasdebernardini
Copy link

I was unable to get a correct fee estimation

    let res = client.estimate_fee(1).unwrap().to_sat_per_vb_ceil();
    println!("{:#?}", res);

returns

100

when the current recommended fee from mempool.space (I'd expect a similar, if not exact value) is

{"fastestFee":5,"halfHourFee":5,"hourFee":5,"economyFee":4,"minimumFee":2}

I think the convert fee rate function needs work.

src/utils.rs Outdated
@@ -41,3 +45,10 @@ pub fn validate_merkle_proof(

cur == merkle_root.to_raw_hash()
}

/// Converts a fee rate in BTC/kB to sats/vbyte.
pub fn convert_fee_rate(fee_rate: f64) -> FeeRate {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this should be pub. Possibly, we should also just inline the require calculation as it's trivial.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I do agree, you can just do this inline.

Copy link
Author

Choose a reason for hiding this comment

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

Ok moved to pub(crate) because it's being called in other files.

src/utils.rs Outdated
pub fn convert_fee_rate(fee_rate: f64) -> FeeRate {
// 1 kB = 1_000 bytes
// 1 vbyte = 4 weight units
FeeRate::from_sat_per_kwu((fee_rate.ceil() as u64 * SATOSHIS_IN_BTC) / 4_000)
Copy link
Contributor

@tnull tnull Jul 25, 2024

Choose a reason for hiding this comment

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

This seems wrong. How about?:

pub fn convert_fee_rate(fee_rate_btc_kvb: f64) -> Option<FeeRate> {
	/// Convert a fee rate in BTC/kvB to sats/vB
	let fee_rate_sat_vb = (fee_rate_btc_kvb * 100_000.0) as u64;
	FeeRate::from_sat_per_vb(fee_rate_sat_vb)
}

Copy link
Collaborator

@oleonardolima oleonardolima Jul 27, 2024

Choose a reason for hiding this comment

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

I think same applies here, you probably want to also use Weight too (if needed for the conversion).

for thought: Is this conversion something we could upstream to rust-bitcoin units module as a new FeeRate module ?

Copy link
Author

Choose a reason for hiding this comment

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

Done

src/api.rs Outdated Show resolved Hide resolved
src/utils.rs Outdated
@@ -41,3 +45,10 @@ pub fn validate_merkle_proof(

cur == merkle_root.to_raw_hash()
}

/// Converts a fee rate in BTC/kB to sats/vbyte.
pub fn convert_fee_rate(fee_rate: f64) -> FeeRate {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I do agree, you can just do this inline.

src/utils.rs Outdated
pub fn convert_fee_rate(fee_rate: f64) -> FeeRate {
// 1 kB = 1_000 bytes
// 1 vbyte = 4 weight units
FeeRate::from_sat_per_kwu((fee_rate.ceil() as u64 * SATOSHIS_IN_BTC) / 4_000)
Copy link
Collaborator

@oleonardolima oleonardolima Jul 27, 2024

Choose a reason for hiding this comment

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

I think same applies here, you probably want to also use Weight too (if needed for the conversion).

for thought: Is this conversion something we could upstream to rust-bitcoin units module as a new FeeRate module ?

@storopoli
Copy link
Author

I think the math is right, but the existing test might need to be updated. What you think?

@tnull
Copy link
Contributor

tnull commented Aug 6, 2024

I think the math is right, but the existing test might need to be updated. What you think?

As discussed offline, I think the tests need fixing.

@storopoli
Copy link
Author

The tests are CRAZY! They are even not reproducible. They pretty much ping a live server electrum.blockstream.info:50001. So I added the tests for the fees to make sure that the fee is > 0.

@tnull
Copy link
Contributor

tnull commented Aug 8, 2024

The tests are CRAZY! They are even not reproducible. They pretty much ping a live server electrum.blockstream.info:50001. So I added the tests for the fees to make sure that the fee is > 0.

Maybe we should switch to use a local regtest electrs that we pre-populate with a certain amount of transactions and blocks to make them ~reproducible? Or, we could consider building a mock Electrum server, which shouldn't be hard to do? Especially given the confusion around fee rate conversion it would be good to test it properly in CI?

@storopoli
Copy link
Author

Moved to #142 so that we can move with this PR into review/merge mode.

impl_batch_call!(self, numbers, estimate_fee, apply_deref)
let fee_rate_num: Result<Vec<f64>, Error> =
impl_batch_call!(self, numbers, estimate_fee, apply_deref);
let fee_rate: Vec<FeeRate> = fee_rate_num?.into_iter().map(convert_fee_rate).collect();
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we avoid the additional allocation here by just collecting a Vec once?

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, I modified the macro impl_batch_call!. Any ideas on how to fix it? Very little experience with macros here...

src/utils.rs Outdated
/// Converts a fee rate in BTC/kB to sats/vbyte.
///
/// # Note
///
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Superfluous blank line?

Copy link
Author

Choose a reason for hiding this comment

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

It is not superfluous. In rustdocs it is common to leave a blank line before and after headers, just like markdown.

Copy link
Contributor

Choose a reason for hiding this comment

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

Fair, excuse the noise.

Copy link
Author

Choose a reason for hiding this comment

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

But I've removed since we are returning an error.

src/utils.rs Outdated
/// 1 sat/vbyte.
pub(crate) fn convert_fee_rate(fee_rate_kvb: f64) -> FeeRate {
let fee_rate_sat_vb = (Amount::ONE_BTC.to_sat() as f64) * fee_rate_kvb;
FeeRate::from_sat_per_vb(fee_rate_sat_vb as u64).unwrap_or(FeeRate::from_sat_per_vb(1).unwrap())
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we avoid the default fallback here? I think we're just about to drop this over at rust-esplora-client (cf bitcoindevkit/rust-esplora-client#80), would make sense to also avoid it here?

Copy link
Author

Choose a reason for hiding this comment

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

Now returns an error.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks, though see #136 (comment)

@@ -880,7 +882,8 @@ impl<T: Read + Write> ElectrumApi for RawClient<T> {

result
.as_f64()
.ok_or_else(|| Error::InvalidResponse(result.clone()))
.map(convert_fee_rate)
.expect("Invalid response")
Copy link
Contributor

Choose a reason for hiding this comment

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

Ugh, please never panic on input we receive via network from a third party. Any calls to convert_fee_rate also need to be fallible and the user needs to handle the error.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree, and you can probably add a new error variant to Error such as: CouldNotConvertFeeRate, or reuse some of the existing ones.

Also changes all fee rates from f64 to proper FeeRate
@@ -189,7 +189,7 @@ pub trait ElectrumApi {
///
/// Takes a list of `numbers` of blocks and returns a list of fee required in
/// **Satoshis per kilobyte** to confirm a transaction in the given number of blocks.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// **Satoshis per kilobyte** to confirm a transaction in the given number of blocks.
/// [`FeeRate`] to confirm a transaction in the given number of blocks.

/// Estimates the fee required in **Bitcoin per kilobyte** to confirm a transaction in `number` blocks.
fn estimate_fee(&self, number: usize) -> Result<f64, Error>;
/// Estimates the fee required in [`FeeRate`] to confirm a transaction in `number` blocks.
fn estimate_fee(&self, number: usize) -> Result<FeeRate, Error>;

/// Returns the minimum accepted fee by the server's node in **Bitcoin, not Satoshi**.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// Returns the minimum accepted fee by the server's node in **Bitcoin, not Satoshi**.
/// Returns the minimum accepted fee by the server's node in [`FeeRate`].

@@ -880,7 +882,8 @@ impl<T: Read + Write> ElectrumApi for RawClient<T> {

result
.as_f64()
.ok_or_else(|| Error::InvalidResponse(result.clone()))
.map(convert_fee_rate)
.expect("Invalid response")
Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree, and you can probably add a new error variant to Error such as: CouldNotConvertFeeRate, or reuse some of the existing ones.

Comment on lines +1178 to +1179
let resp = client.relay_fee().unwrap().to_sat_per_vb_ceil();
assert!(resp > 0);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I probably need some time to take a better look at the tests to wrap my mind around it, I'm afraid we can be missing something due to the current state of testing at crate level 🤔

Comment on lines +312 to +313
/// There was an error parsing a fee rate
FeeRate(String),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh, now I see that you already added a new variant here.

@@ -365,6 +366,7 @@ impl Display for Error {
Error::MissingDomain => f.write_str("Missing domain while it was explicitly asked to validate it"),
Error::CouldntLockReader => f.write_str("Couldn't take a lock on the reader mutex. This means that there's already another reader thread is running"),
Error::Mpsc => f.write_str("Broken IPC communication channel: the other thread probably has exited"),
Error::FeeRate(e) => f.write_str(e),
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: You can probably leave the error message here, as it becomes a single point of change in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants