Skip to content

Commit bb5c405

Browse files
authored
feat(ckbtc): Use 25th percentile fee for UTXO consolidation (#8150)
Switch from median fee to 25th percentile fee when creating UTXO consolidation transactions.
1 parent de6cc72 commit bb5c405

File tree

5 files changed

+83
-8
lines changed

5 files changed

+83
-8
lines changed

rs/bitcoin/ckbtc/minter/src/fees/mod.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use crate::tx::UnsignedTransaction;
33
use crate::{Network, fake_sign};
44
use ic_btc_interface::{MillisatoshiPerByte, Satoshi};
55
use std::cmp::max;
6+
#[cfg(test)]
7+
mod tests;
68

79
pub trait FeeEstimator {
810
const DUST_LIMIT: u64;
@@ -14,6 +16,15 @@ pub trait FeeEstimator {
1416
fn estimate_median_fee(
1517
&self,
1618
fee_percentiles: &[MillisatoshiPerByte],
19+
) -> Option<MillisatoshiPerByte> {
20+
self.estimate_nth_fee(fee_percentiles, 50)
21+
}
22+
23+
/// Estimate the n-th percentile fees (n < 100) based on the given fee percentiles (slice of fee rates in milli base unit per vbyte/byte).
24+
fn estimate_nth_fee(
25+
&self,
26+
fee_percentiles: &[MillisatoshiPerByte],
27+
nth: usize,
1728
) -> Option<MillisatoshiPerByte>;
1829

1930
/// Evaluate the fee necessary to cover the minter's cycles consumption.
@@ -90,19 +101,20 @@ impl FeeEstimator for BitcoinFeeEstimator {
90101

91102
const MIN_RELAY_FEE_RATE_INCREASE: MillisatoshiPerByte = 1_000;
92103

93-
fn estimate_median_fee(
104+
fn estimate_nth_fee(
94105
&self,
95106
fee_percentiles: &[MillisatoshiPerByte],
107+
nth: usize,
96108
) -> Option<MillisatoshiPerByte> {
97109
/// The default fee we use on regtest networks.
98110
const DEFAULT_REGTEST_FEE: MillisatoshiPerByte = 5_000;
99111

100112
let median_fee = match &self.network {
101113
Network::Mainnet | Network::Testnet => {
102-
if fee_percentiles.len() < 100 {
114+
if fee_percentiles.len() < 100 || nth >= 100 {
103115
return None;
104116
}
105-
Some(fee_percentiles[50])
117+
Some(fee_percentiles[nth])
106118
}
107119
Network::Regtest => Some(DEFAULT_REGTEST_FEE),
108120
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
use super::*;
2+
use crate::test_fixtures::bitcoin_fee_estimator;
3+
4+
#[test]
5+
fn test_estimate_nth_fee() {
6+
let estimator = bitcoin_fee_estimator();
7+
let min_fee = estimator.minimum_fee_per_vbyte();
8+
assert_eq!(estimator.estimate_nth_fee(&[], 10), None);
9+
let percentiles = (1..=100).map(|i| i * 150).collect::<Vec<_>>();
10+
for i in 0..100 {
11+
assert_eq!(
12+
estimator.estimate_nth_fee(&percentiles, i),
13+
Some(percentiles[i].max(min_fee))
14+
);
15+
}
16+
assert_eq!(estimator.estimate_nth_fee(&percentiles, 100), None);
17+
}

rs/bitcoin/ckbtc/minter/src/lib.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,34 @@ pub async fn estimate_fee_per_vbyte<R: CanisterRuntime>(
263263
}
264264
}
265265

266+
/// Returns an estimate for transaction fees in the 25th percentile in millisatoshi per vbyte. Returns
267+
/// None if the Bitcoin canister is unavailable or does not have enough data for
268+
/// an estimate yet.
269+
pub async fn estimate_25th_fee_per_vbyte<R: CanisterRuntime>(
270+
runtime: &R,
271+
) -> Option<MillisatoshiPerByte> {
272+
let btc_network = state::read_state(|s| s.btc_network);
273+
match runtime
274+
.get_current_fee_percentiles(&bitcoin_canister::GetCurrentFeePercentilesRequest {
275+
network: btc_network.into(),
276+
})
277+
.await
278+
{
279+
Ok(fees) => {
280+
let fee_estimator = state::read_state(|s| runtime.fee_estimator(s));
281+
fee_estimator.estimate_nth_fee(&fees, 25)
282+
}
283+
Err(err) => {
284+
log!(
285+
Priority::Info,
286+
"[estimate_25th_fee_per_vbyte]: failed to get 25th percentile fee per vbyte: {}",
287+
err
288+
);
289+
None
290+
}
291+
}
292+
}
293+
266294
fn reimburse_canceled_requests<R: CanisterRuntime>(
267295
state: &mut state::CkBtcMinterState,
268296
requests: BTreeSet<state::RetrieveBtcRequest>,
@@ -1485,8 +1513,7 @@ pub async fn consolidate_utxos<R: CanisterRuntime>(
14851513

14861514
let ecdsa_public_key = updates::get_btc_address::init_ecdsa_public_key().await;
14871515

1488-
// TODO DEFI-2552: use 25% percentile
1489-
let fee_millisatoshi_per_vbyte = estimate_fee_per_vbyte(runtime)
1516+
let fee_millisatoshi_per_vbyte = estimate_25th_fee_per_vbyte(runtime)
14901517
.await
14911518
.ok_or(ConsolidateUtxosError::EstimateFeeNotAvailable)?;
14921519

rs/bitcoin/ckbtc/minter/tests/tests.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,6 +2062,13 @@ fn test_utxo_consolidation_multiple() {
20622062
);
20632063
let transfer_index = result.0.to_u64().unwrap();
20642064

2065+
// Set fee percentiles so that the 25th percentile is 2000 and the median (50th) is 5000.
2066+
// We set the first 40 percentiles to 2000 and the rest to 5000.
2067+
let fees: Vec<u64> = std::iter::repeat_n(2000, 40)
2068+
.chain(std::iter::repeat_n(5000, 60))
2069+
.collect();
2070+
ckbtc.set_fee_percentiles(&fees);
2071+
20652072
// Test two consolidations
20662073
for i in 1..=2 {
20672074
// Upgrade to trigger consolidation task by setting a lower threshold.
@@ -2115,6 +2122,18 @@ fn test_utxo_consolidation_multiple() {
21152122
assert_eq!(fee_account_balance, new_fee_account_balance + burn_amount);
21162123
fee_account_balance = new_fee_account_balance;
21172124

2125+
// Verify that the fee rate corresponds to the 25th percentile (2000).
2126+
// Since signatures length vary slightly, we check if the rate is close to 2000.
2127+
// It definitely shouldn't be close to 5000 (the median).
2128+
let tx_fee = total_input - total_output;
2129+
let vsize = tx.vsize();
2130+
let fee_rate = tx_fee * 1000 / vsize as u64;
2131+
assert!(
2132+
(1900..2100).contains(&fee_rate),
2133+
"Fee rate {} should be around 2000 (25th percentile), not 5000 (median)",
2134+
fee_rate
2135+
);
2136+
21182137
// Finalize the new transaction.
21192138
ckbtc.finalize_transaction(tx);
21202139
assert_eq!(ckbtc.await_finalization(burn_index, 10), txid);

rs/dogecoin/ckdoge/minter/src/fees/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,15 @@ impl FeeEstimator for DogecoinFeeEstimator {
4949
// corresponding to 10k millikoinus/byte
5050
const MIN_RELAY_FEE_RATE_INCREASE: u64 = 10_000;
5151

52-
fn estimate_median_fee(&self, fee_percentiles: &[u64]) -> Option<u64> {
52+
fn estimate_nth_fee(&self, fee_percentiles: &[u64], nth: usize) -> Option<u64> {
5353
const DEFAULT_REGTEST_FEE: MillisatoshiPerByte = DogecoinFeeEstimator::DUST_LIMIT * 1_000;
5454

5555
match &self.network {
5656
Network::Mainnet => {
57-
if fee_percentiles.len() < 100 {
57+
if fee_percentiles.len() < 100 || nth >= 100 {
5858
return None;
5959
}
60-
Some(fee_percentiles[50])
60+
Some(fee_percentiles[nth])
6161
}
6262
Network::Regtest => Some(DEFAULT_REGTEST_FEE),
6363
}

0 commit comments

Comments
 (0)