Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
71f30cb
DEFI-2458: fee estimator
gregorydemay Nov 14, 2025
711a989
DEFI-2458: store last_median_fee_per_vbyte
gregorydemay Nov 14, 2025
f016eae
DEFI-2458: instantiate FeeEstimator from CanisterRuntime
gregorydemay Nov 14, 2025
fbde32f
DEFI-2458: copied Bitcoin implementation for DogecoinFeeEstimator
gregorydemay Nov 14, 2025
9144be0
DEFI-2458: evalue minter fee
gregorydemay Nov 14, 2025
9bf8241
DEFI-2458: evaluate transaction fees
gregorydemay Nov 18, 2025
ec83e2e
DEFI-2458: use FeeEstimator to build transactions
gregorydemay Nov 18, 2025
1f4949b
DEFI-2458: CanisterRuntime generic over type of FeeEstimator
gregorydemay Nov 18, 2025
bd0939e
DEFI-2458: clean-up
gregorydemay Nov 18, 2025
d78c33d
DEFI-2458: Dogecoin evaluate_transaction_fee
gregorydemay Nov 18, 2025
ab4cc47
DEFI-2458: fix minimum_withrawal_amount
gregorydemay Nov 18, 2025
725391f
DEFI-2458: unit test for Dogecoin minimum_withrawal_amount
gregorydemay Nov 18, 2025
a2df781
DEFI-2458: rename
gregorydemay Nov 18, 2025
03bb227
Merge branch 'master' into gdemay/DEFI-2458-ckdoge-tx-fees
gregorydemay Nov 18, 2025
95acd65
DEFI-2548: docs estimate_median_fee
gregorydemay Nov 20, 2025
b980924
DEFI-2548: rename associated type
gregorydemay Nov 20, 2025
7f4cdf2
DEFI-2548: DUST_LIMIT constant
gregorydemay Nov 20, 2025
fc8bbdc
DEFI-2548: clean-up evaluate_minter_fee
gregorydemay Nov 20, 2025
8da1f30
DEFI-2548: rename build_bitcoin_unsigned_transaction
gregorydemay Nov 20, 2025
a84220e
DEFi-2458: fix system test
gregorydemay Nov 20, 2025
415ab61
DEFI-2548: formatting
gregorydemay Nov 20, 2025
6b23f0b
DEFI-2548: typos
gregorydemay Nov 21, 2025
3daf88c
DEFI-2548: docs BitcoinFeeEstimator
gregorydemay Nov 21, 2025
b3bf7fb
DEFI-2548: docs PER_REQUEST_SIZE_BOUND
gregorydemay Nov 21, 2025
69fc403
DEFI-2548: remove comment
gregorydemay Nov 21, 2025
257eb2a
DEFI-2548: change Dogecoin Regtest dust limit
gregorydemay Nov 21, 2025
2a38b95
Merge branch 'master' into gdemay/DEFI-2458-ckdoge-tx-fees
gregorydemay Nov 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions rs/bitcoin/ckbtc/minter/src/fees/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
use crate::state::CkBtcMinterState;
use crate::tx::UnsignedTransaction;
use crate::{Network, fake_sign};
use ic_btc_interface::{MillisatoshiPerByte, Satoshi};
use std::cmp::max;

pub trait FeeEstimator {
fn estimate_median_fee(
&self,
fee_percentiles: &[MillisatoshiPerByte],
) -> Option<MillisatoshiPerByte>;

/// Evaluate the fee necessary to cover the minter's cycles consumption.
fn evaluate_minter_fee(&self, num_inputs: u64, num_outputs: u64) -> Satoshi;

/// Evaluate transaction fee with the given fee rate (in milli base unit per vbyte/byte)
fn evaluate_transaction_fee(&self, tx: &UnsignedTransaction, fee_rate: u64) -> u64;

/// Compute a new minimum withdrawal amount based on the current fee rate
fn fee_based_minimum_withrawal_amount(&self, median_fee: MillisatoshiPerByte) -> Satoshi;
}

pub struct BitcoinFeeEstimator {
network: Network,
retrieve_btc_min_amount: u64,
check_fee: u64,
}

impl BitcoinFeeEstimator {
pub fn new(network: Network, retrieve_btc_min_amount: u64, check_fee: u64) -> Self {
Self {
network,
retrieve_btc_min_amount,
check_fee,
}
}

pub fn from_state(state: &CkBtcMinterState) -> Self {
Self::new(
state.btc_network,
state.retrieve_btc_min_amount,
state.check_fee,
)
}

/// An estimated fee per vbyte of 142 millistatoshis per vbyte was selected around 2025.06.21 01:09:50 UTC
/// for Bitcoin Mainnet, whereas the median fee around that time should have been 2_000.
/// Until we know the root cause, we ensure that the estimated fee has a meaningful minimum value.
const fn minimum_fee_per_vbyte(&self) -> MillisatoshiPerByte {
match &self.network {
Network::Mainnet => 1_500,
Network::Testnet => 1_000,
Network::Regtest => 0,
}
}
}

impl FeeEstimator for BitcoinFeeEstimator {
fn estimate_median_fee(
&self,
fee_percentiles: &[MillisatoshiPerByte],
) -> Option<MillisatoshiPerByte> {
/// The default fee we use on regtest networks.
const DEFAULT_REGTEST_FEE: MillisatoshiPerByte = 5_000;

let median_fee = match &self.network {
Network::Mainnet | Network::Testnet => {
if fee_percentiles.len() < 100 {
return None;
}
Some(fee_percentiles[50])
}
Network::Regtest => Some(DEFAULT_REGTEST_FEE),
};
median_fee.map(|f| f.max(self.minimum_fee_per_vbyte()))
}

fn evaluate_minter_fee(&self, num_inputs: u64, num_outputs: u64) -> u64 {
const MINTER_FEE_PER_INPUT: u64 = 146;
const MINTER_FEE_PER_OUTPUT: u64 = 4;
const MINTER_FEE_CONSTANT: u64 = 26;
const MINTER_ADDRESS_DUST_LIMIT: Satoshi = 300;

max(
MINTER_FEE_PER_INPUT * num_inputs
+ MINTER_FEE_PER_OUTPUT * num_outputs
+ MINTER_FEE_CONSTANT,
MINTER_ADDRESS_DUST_LIMIT,
)
}

/// Returns the minimum withdrawal amount based on the current median fee rate (in millisatoshi per byte).
/// The returned amount is in satoshi.
fn fee_based_minimum_withrawal_amount(&self, median_fee: MillisatoshiPerByte) -> Satoshi {
match self.network {
Network::Mainnet | Network::Testnet => {
const PER_REQUEST_RBF_BOUND: u64 = 22_100;
const PER_REQUEST_VSIZE_BOUND: u64 = 221;
const PER_REQUEST_MINTER_FEE_BOUND: u64 = 305;

let median_fee_rate = median_fee / 1_000;
((PER_REQUEST_RBF_BOUND
+ PER_REQUEST_VSIZE_BOUND * median_fee_rate
+ PER_REQUEST_MINTER_FEE_BOUND
+ self.check_fee)
/ 50_000) //TODO DEFI-2187: adjust increment of minimum withdrawal amount to be a multiple of retrieve_btc_min_amount/2
* 50_000
+ self.retrieve_btc_min_amount
}
Network::Regtest => self.retrieve_btc_min_amount,
}
}

fn evaluate_transaction_fee(
&self,
unsigned_tx: &UnsignedTransaction,
fee_per_vbyte: u64,
) -> u64 {
let tx_vsize = fake_sign(unsigned_tx).vsize();
(tx_vsize as u64 * fee_per_vbyte) / 1000
}
}
89 changes: 54 additions & 35 deletions rs/bitcoin/ckbtc/minter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ use std::cmp::max;
use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;

use crate::state::{CkBtcMinterState, read_state};
use crate::fees::{BitcoinFeeEstimator, FeeEstimator};
use crate::state::{CkBtcMinterState, mutate_state, read_state};
use crate::updates::get_btc_address;
use crate::updates::retrieve_btc::BtcAddressCheckStatus;
pub use ic_btc_checker::CheckTransactionResponse;
Expand All @@ -28,6 +29,7 @@ pub use ic_btc_interface::{MillisatoshiPerByte, OutPoint, Page, Satoshi, Txid, U

pub mod address;
pub mod dashboard;
pub mod fees;
pub mod guard;
pub mod lifecycle;
pub mod logs;
Expand Down Expand Up @@ -239,27 +241,6 @@ async fn fetch_main_utxos<R: CanisterRuntime>(
})
}

/// Returns the minimum withdrawal amount based on the current median fee rate (in millisatoshi per byte).
/// The returned amount is in satoshi.
fn compute_min_withdrawal_amount(
median_fee_rate_e3s: MillisatoshiPerByte,
min_withdrawal_amount: u64,
check_fee: u64,
) -> u64 {
const PER_REQUEST_RBF_BOUND: u64 = 22_100;
const PER_REQUEST_VSIZE_BOUND: u64 = 221;
const PER_REQUEST_MINTER_FEE_BOUND: u64 = 305;

let median_fee_rate = median_fee_rate_e3s / 1_000;
((PER_REQUEST_RBF_BOUND
+ PER_REQUEST_VSIZE_BOUND * median_fee_rate
+ PER_REQUEST_MINTER_FEE_BOUND
+ check_fee)
/ 50_000)
* 50_000
+ min_withdrawal_amount
}

/// Returns an estimate for transaction fees in millisatoshi per vbyte. Returns
/// None if the Bitcoin canister is unavailable or does not have enough data for
/// an estimate yet.
Expand All @@ -274,14 +255,24 @@ pub async fn estimate_fee_per_vbyte<R: CanisterRuntime>(
.await
{
Ok(fees) => {
if btc_network == Network::Regtest {
return state::read_state(|s| s.estimate_median_fee_per_vbyte());
let fee_estimator = state::read_state(|s| runtime.fee_estimator(s));
match fee_estimator.estimate_median_fee(&fees) {
Some(median_fee) => {
let fee_based_retrieve_btc_min_amount =
fee_estimator.fee_based_minimum_withrawal_amount(median_fee);
log!(
Priority::Debug,
"[estimate_fee_per_vbyte]: update median fee per vbyte to {median_fee} and fee-based minimum retrieve amount to {fee_based_retrieve_btc_min_amount} with {fees:?}"
);
mutate_state(|s| {
s.last_fee_per_vbyte = fees;
s.last_median_fee_per_vbyte = Some(median_fee);
s.fee_based_retrieve_btc_min_amount = fee_based_retrieve_btc_min_amount;
});
Some(median_fee)
}
None => None,
}
log!(
Priority::Debug,
"[estimate_fee_per_vbyte]: update median fee per vbyte with {fees:?}"
);
state::mutate_state(|s| s.update_median_fee_per_vbyte(fees))
}
Err(err) => {
log!(
Expand Down Expand Up @@ -364,6 +355,7 @@ async fn submit_pending_requests<R: CanisterRuntime>(runtime: &R) {
Some(fee) => fee,
None => return,
};
let fee_estimator = read_state(|s| runtime.fee_estimator(s));

let maybe_sign_request = state::mutate_state(|s| {
let batch = s.build_batch(MAX_REQUESTS_PER_BATCH);
Expand All @@ -382,6 +374,7 @@ async fn submit_pending_requests<R: CanisterRuntime>(runtime: &R) {
outputs,
main_address,
fee_millisatoshi_per_vbyte,
&fee_estimator,
) {
Ok((unsigned_tx, change_output, total_fee, utxos)) => {
for req in batch.iter() {
Expand Down Expand Up @@ -765,6 +758,7 @@ async fn finalize_requests<R: CanisterRuntime>(runtime: &R, force_resubmit: bool
None => return,
};
let key_name = state::read_state(|s| s.ecdsa_key_name.clone());
let fee_estimator = state::read_state(|s| runtime.fee_estimator(s));
resubmit_transactions(
&key_name,
fee_per_vbyte,
Expand All @@ -780,6 +774,7 @@ async fn finalize_requests<R: CanisterRuntime>(runtime: &R, force_resubmit: bool
})
},
runtime,
&fee_estimator,
)
.await
}
Expand All @@ -788,6 +783,7 @@ pub async fn resubmit_transactions<
R: CanisterRuntime,
F: Fn(&OutPoint) -> Option<Account>,
G: Fn(Txid, state::SubmittedBtcTransaction, state::eventlog::ReplacedReason),
Fee: FeeEstimator,
>(
key_name: &str,
fee_per_vbyte: u64,
Expand All @@ -799,6 +795,7 @@ pub async fn resubmit_transactions<
lookup_outpoint_account: F,
replace_transaction: G,
runtime: &R,
fee_estimator: &Fee,
) {
for (old_txid, submitted_tx) in transactions {
let tx_fee_per_vbyte = match submitted_tx.fee_per_vbyte {
Expand Down Expand Up @@ -828,6 +825,7 @@ pub async fn resubmit_transactions<
outputs,
main_address.clone(),
tx_fee_per_vbyte,
fee_estimator,
) {
Err(BuildTxError::InvalidTransaction(err)) => {
log!(
Expand Down Expand Up @@ -860,6 +858,7 @@ pub async fn resubmit_transactions<
outputs,
main_address.clone(),
fee_per_vbyte, // Use normal fee
fee_estimator,
)
}
result => result,
Expand Down Expand Up @@ -1157,11 +1156,12 @@ pub enum BuildTxError {
/// result.is_err() => minter_utxos' == minter_utxos
/// ```
///
pub fn build_unsigned_transaction(
pub fn build_unsigned_transaction<F: FeeEstimator>(
available_utxos: &mut BTreeSet<Utxo>,
outputs: Vec<(BitcoinAddress, Satoshi)>,
main_address: BitcoinAddress,
fee_per_vbyte: u64,
fee_estimator: &F,
) -> Result<
(
tx::UnsignedTransaction,
Expand All @@ -1174,7 +1174,13 @@ pub fn build_unsigned_transaction(
assert!(!outputs.is_empty());
let amount = outputs.iter().map(|(_, amount)| amount).sum::<u64>();
let inputs = utxos_selection(amount, available_utxos, outputs.len());
match build_unsigned_transaction_from_inputs(&inputs, outputs, main_address, fee_per_vbyte) {
match build_unsigned_transaction_from_inputs(
&inputs,
outputs,
main_address,
fee_per_vbyte,
fee_estimator,
) {
Ok((tx, change, total_fee)) => Ok((tx, change, total_fee, inputs)),
Err(err) => {
// Undo mutation to available_utxos in the error case
Expand All @@ -1186,11 +1192,12 @@ pub fn build_unsigned_transaction(
}
}

pub fn build_unsigned_transaction_from_inputs(
pub fn build_unsigned_transaction_from_inputs<F: FeeEstimator>(
input_utxos: &[Utxo],
outputs: Vec<(BitcoinAddress, Satoshi)>,
main_address: BitcoinAddress,
fee_per_vbyte: u64,
fee_estimator: &F,
) -> Result<(tx::UnsignedTransaction, state::ChangeOutput, WithdrawalFee), BuildTxError> {
assert!(!outputs.is_empty());
/// Having a sequence number lower than (0xffffffff - 1) signals the use of replacement by fee.
Expand Down Expand Up @@ -1218,7 +1225,8 @@ pub fn build_unsigned_transaction_from_inputs(

debug_assert!(inputs_value >= amount);

let minter_fee = evaluate_minter_fee(input_utxos.len() as u64, (outputs.len() + 1) as u64);
let minter_fee =
fee_estimator.evaluate_minter_fee(input_utxos.len() as u64, (outputs.len() + 1) as u64);

let change = inputs_value - amount;
let change_output = state::ChangeOutput {
Expand Down Expand Up @@ -1257,8 +1265,7 @@ pub fn build_unsigned_transaction_from_inputs(
lock_time: 0,
};

let tx_vsize = fake_sign(&unsigned_tx).vsize();
let fee = (tx_vsize as u64 * fee_per_vbyte) / 1000;
let fee = fee_estimator.evaluate_transaction_fee(&unsigned_tx, fee_per_vbyte);

if fee + minter_fee > amount {
return Err(BuildTxError::AmountTooLow);
Expand Down Expand Up @@ -1408,6 +1415,9 @@ pub fn estimate_retrieve_btc_fee(

#[async_trait]
pub trait CanisterRuntime {
/// Type used to estimate fees.
type Fee: FeeEstimator;

/// Returns the caller of the current call.
fn caller(&self) -> Principal {
ic_cdk::api::msg_caller()
Expand Down Expand Up @@ -1452,6 +1462,9 @@ pub trait CanisterRuntime {
/// Returns the frequency at which fee percentiles are refreshed.
fn refresh_fee_percentiles_frequency(&self) -> Duration;

/// How to estimate fees.
fn fee_estimator(&self, state: &CkBtcMinterState) -> Self::Fee;

/// Retrieves the current transaction fee percentiles.
async fn get_current_fee_percentiles(
&self,
Expand Down Expand Up @@ -1501,11 +1514,17 @@ pub struct IcCanisterRuntime {}

#[async_trait]
impl CanisterRuntime for IcCanisterRuntime {
type Fee = BitcoinFeeEstimator;

fn refresh_fee_percentiles_frequency(&self) -> Duration {
const ONE_HOUR: Duration = Duration::from_secs(3_600);
ONE_HOUR
}

fn fee_estimator(&self, state: &CkBtcMinterState) -> BitcoinFeeEstimator {
BitcoinFeeEstimator::from_state(state)
}

async fn get_current_fee_percentiles(
&self,
request: &GetCurrentFeePercentilesRequest,
Expand Down
2 changes: 1 addition & 1 deletion rs/bitcoin/ckbtc/minter/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ fn estimate_withdrawal_fee(arg: EstimateFeeArg) -> WithdrawalFee {
ic_ckbtc_minter::estimate_retrieve_btc_fee(
&s.available_utxos,
arg.amount,
s.estimate_median_fee_per_vbyte()
s.last_median_fee_per_vbyte
.expect("Bitcoin current fee percentiles not retrieved yet."),
)
})
Expand Down
Loading
Loading