]> Untitled Git - bdk-cli/commitdiff
fix(fee_rate): parse fee_rate at clap and keep sub-sat/vB precision
authorVihiga Tyonum <withtvpeter@gmail.com>
Sat, 26 Sep 2026 08:22:34 +0000 (09:22 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Mon, 28 Sep 2026 17:35:43 +0000 (18:35 +0100)
`--fee_rate` was taken as `f32` in the tx-building commands and cast
`as u64`, which both truncates and saturates. When `FeeRate::from_sat_per_vb`
returned `None` the value was silently skipped in `create_tx`, `create_sp_tx`
and `create_dns_tx`, `bump_fee` fell back to `FeeRate::BROADCAST_MIN`, and
`send_payjoin` took a `u64` and panicked, so the user got a fee they never
asked for or no transaction at all.

- add `parse_fee_rate` and pass it as a clap `value_parser`, so invalid
  values are rejected
- store `FeeRate` instead of `f32`/`u64`, converting via sat/kwu so
  fractional rates keep 1/250 sat/vB precision rather than truncating
- drop the `unwrap_or(FeeRate::BROADCAST_MIN)` fallback in `bump_fee` and
  the `expect` in `send_payjoin`
- cover parsing and rejection with unit and integration tests

Fixes #325

CHANGELOG.md
src/error.rs
src/handlers/dns/mod.rs
src/handlers/offline.rs
src/handlers/online.rs
src/handlers/payjoin/mod.rs
src/utils/common.rs
tests/integration/offline.rs

index 636e8034bb415d01e17ae402c1db8873bb091a5e..5d127b14f79895a2da387a456c67bc1ef20b0bb9 100644 (file)
@@ -8,6 +8,8 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details.
 - Added support for Multipath (two-paths) descriptors.
 - Fixed the data directory and config.toml permission being world-readable (0755/0644) to 0700/0600 on Unix.
 - Fixed `create_tx` and `bump_fee` panicking on malformed `--utxos` and `--add_data` values instead of returning an error
+- Fixed `--fee_rate` silently truncating to a whole sat/vB, falling back to a default, or producing a zero-fee transaction, unusable values are now rejected
+
 
 ## [4.0.0]
 
index 9ceb571ab1062b7c56c80ceee35d53845b1d58d7..685ccdff6023f5757e78dbf29d169b44d306f89a 100644 (file)
@@ -75,6 +75,9 @@ pub enum BDKCliError {
     #[error("PsbtError: {0}")]
     PsbtError(#[from] bdk_wallet::bitcoin::psbt::Error),
 
+    #[error("PushBytes error: {0}")]
+    PushBytesError(#[from] bdk_wallet::bitcoin::script::PushBytesError),
+
     #[cfg(feature = "sqlite")]
     #[error("Rusqlite error: {0}")]
     RusqliteError(Box<bdk_wallet::rusqlite::Error>),
index 6719db087782208fcf9f1155687a2b0100d6507a..a57336906b87801078b375489d3dbc83201a3090 100644 (file)
@@ -6,7 +6,7 @@ use crate::handlers::dns::dns_payment_instructions::{
 };
 use crate::handlers::{AppContext, AsyncAppCommand, Init, OfflineOperations};
 use crate::utils::types::{PsbtResult, StatusResult};
-use crate::utils::{parse_dns_recipient, parse_outpoint, parse_recipient};
+use crate::utils::{parse_dns_recipient, parse_fee_rate, parse_outpoint, parse_recipient};
 use bdk_wallet::KeychainKind;
 use bdk_wallet::bitcoin::base64::Engine;
 use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD;
@@ -57,8 +57,8 @@ pub struct CreateDnsTxCommand {
     pub utxos: Option<Vec<OutPoint>>,
     #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)]
     pub unspendable: Option<Vec<OutPoint>>,
-    #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")]
-    pub fee_rate: Option<f32>,
+    #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate", value_parser = parse_fee_rate)]
+    pub fee_rate: Option<FeeRate>,
     #[arg(env = "EXT_POLICY", long = "external_policy")]
     pub external_policy: Option<String>,
     #[arg(env = "INT_POLICY", long = "internal_policy")]
@@ -129,9 +129,7 @@ impl AsyncAppCommand<AppContext<OfflineOperations<'_>>> for CreateDnsTxCommand {
         if self.offline_signer {
             tx_builder.add_global_xpubs();
         }
-        if let Some(fee_rate) = self.fee_rate
-            && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64)
-        {
+        if let Some(fee_rate) = self.fee_rate {
             tx_builder.fee_rate(fee_rate);
         }
         if let Some(utxos) = &self.utxos {
@@ -142,13 +140,9 @@ impl AsyncAppCommand<AppContext<OfflineOperations<'_>>> for CreateDnsTxCommand {
         }
         if let Some(base64_data) = &self.add_data {
             let op_return_data = BASE64_STANDARD.decode(base64_data)?;
-            tx_builder.add_data(
-                &PushBytesBuf::try_from(op_return_data)
-                    .map_err(|e| Error::Generic(e.to_string()))?,
-            );
+            tx_builder.add_data(&PushBytesBuf::try_from(op_return_data)?);
         } else if let Some(string_data) = &self.add_string {
-            let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec())
-                .map_err(|e| Error::Generic(e.to_string()))?;
+            let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec())?;
             tx_builder.add_data(&data);
         }
 
index 918a44731fa18a4429bdba04162b4cd4f47ef4c9..4b623b4948386e72ab9397720c663819182b57f3 100644 (file)
@@ -7,7 +7,7 @@ use crate::utils::types::{
     AddressResult, BalanceResult, KeychainPair, PsbtResult, RawPsbt, TransactionDetails,
     UnspentDetails,
 };
-use crate::utils::{parse_outpoint, parse_recipient};
+use crate::utils::{parse_fee_rate, parse_outpoint, parse_recipient};
 use bdk_wallet::bitcoin::base64::Engine;
 use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD;
 use bdk_wallet::bitcoin::script::PushBytesBuf;
@@ -217,8 +217,8 @@ pub struct CreateTxCommand {
     pub unspendable: Option<Vec<OutPoint>>,
 
     /// Fee rate to use in sat/vbyte.
-    #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")]
-    pub fee_rate: Option<f32>,
+    #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate", value_parser = parse_fee_rate)]
+    pub fee_rate: Option<FeeRate>,
 
     /// Selects which policy should be used to satisfy the external descriptor.
     #[arg(env = "EXT_POLICY", long = "external_policy")]
@@ -281,9 +281,7 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateTxCommand {
             tx_builder.add_global_xpubs();
         }
 
-        if let Some(fee_rate) = self.fee_rate
-            && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64)
-        {
+        if let Some(fee_rate) = self.fee_rate {
             tx_builder.fee_rate(fee_rate);
         }
 
@@ -297,13 +295,9 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateTxCommand {
 
         if let Some(base64_data) = &self.add_data {
             let op_return_data = BASE64_STANDARD.decode(base64_data)?;
-            tx_builder.add_data(
-                &PushBytesBuf::try_from(op_return_data)
-                    .map_err(|e| Error::Generic(e.to_string()))?,
-            );
+            tx_builder.add_data(&PushBytesBuf::try_from(op_return_data)?);
         } else if let Some(string_data) = &self.add_string {
-            let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec())
-                .map_err(|e| Error::Generic(e.to_string()))?;
+            let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec())?;
             tx_builder.add_data(&data);
         }
 
@@ -351,8 +345,8 @@ pub struct CreateSpTxCommand {
     #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)]
     pub unspendable: Option<Vec<OutPoint>>,
     /// Fee rate to use in sat/vbyte.
-    #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")]
-    pub fee_rate: Option<f32>,
+    #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate", value_parser = parse_fee_rate)]
+    pub fee_rate: Option<FeeRate>,
     /// Selects which policy should be used to satisfy the external descriptor.
     #[arg(env = "EXT_POLICY", long = "external_policy")]
     pub external_policy: Option<String>,
@@ -438,9 +432,7 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateSpTxCommand {
             tx_builder.add_global_xpubs();
         }
 
-        if let Some(fee_rate) = self.fee_rate
-            && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64)
-        {
+        if let Some(fee_rate) = self.fee_rate {
             tx_builder.fee_rate(fee_rate);
         }
 
@@ -454,13 +446,9 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateSpTxCommand {
 
         if let Some(base64_data) = &self.add_data {
             let op_return_data = BASE64_STANDARD.decode(base64_data)?;
-            tx_builder.add_data(
-                &PushBytesBuf::try_from(op_return_data)
-                    .map_err(|e| Error::Generic(e.to_string()))?,
-            );
+            tx_builder.add_data(&PushBytesBuf::try_from(op_return_data)?);
         } else if let Some(string_data) = &self.add_string {
-            let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec())
-                .map_err(|e| Error::Generic(e.to_string()))?;
+            let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec())?;
             tx_builder.add_data(&data);
         }
 
@@ -571,9 +559,10 @@ pub struct BumpFeeCommand {
         env = "SATS_VBYTE",
         short = 'f',
         long = "fee_rate",
-        default_value = "1.0"
+        default_value = "1.0",
+        value_parser = parse_fee_rate
     )]
-    pub fee_rate: f32,
+    pub fee_rate: FeeRate,
 }
 
 impl AppCommand<AppContext<OfflineOperations<'_>>> for BumpFeeCommand {
@@ -583,9 +572,7 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for BumpFeeCommand {
         let wallet = &mut ctx.state.wallet;
 
         let mut tx_builder = wallet.build_fee_bump(self.txid)?;
-        let fee_rate =
-            FeeRate::from_sat_per_vb(self.fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN);
-        tx_builder.fee_rate(fee_rate);
+        tx_builder.fee_rate(self.fee_rate);
 
         if let Some(address) = &self.shrink_address {
             let script_pubkey = address.script_pubkey();
index f2900cbca08ac5ec8d0af84ab3632ec5b9b5b903..25baf889711d04cfaed8da127c4cb41079e7d583 100644 (file)
@@ -1,9 +1,10 @@
-use clap::Parser;
-
 #[cfg(feature = "electrum")]
 use crate::client::BlockchainClient::Electrum;
 #[cfg(feature = "cbf")]
 use crate::client::{BlockchainClient::KyotoClient, sync_kyoto_client};
+use crate::utils::parse_fee_rate;
+use bdk_wallet::bitcoin::FeeRate;
+use clap::Parser;
 #[cfg(feature = "esplora")]
 use {crate::client::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt};
 #[cfg(feature = "rpc")]
@@ -438,9 +439,10 @@ pub struct SendPayjoinCommand {
         env = "PAYJOIN_SENDER_FEE_RATE",
         short = 'f',
         long = "fee_rate",
-        required = true
+        required = true,
+        value_parser = parse_fee_rate
     )]
-    fee_rate: u64,
+    fee_rate: FeeRate,
 }
 #[cfg(any(
     feature = "electrum",
index 7a2fe36a40965c98ed6d71a29624c276904b3264..c78fa0af121637cc9483306ce078ef1a9b185e63 100644 (file)
@@ -172,7 +172,7 @@ impl<'a> PayjoinManager<'a> {
     pub async fn send_payjoin(
         &mut self,
         uri: String,
-        fee_rate: u64,
+        fee_rate: FeeRate,
         ohttp_relays: Vec<String>,
         blockchain_client: &BlockchainClient,
     ) -> Result<String, Error> {
@@ -189,8 +189,6 @@ impl<'a> PayjoinManager<'a> {
             .amount
             .ok_or_else(|| Error::Generic("Amount is not specified in the URI.".to_string()))?;
 
-        let fee_rate = FeeRate::from_sat_per_vb(fee_rate).expect("Provided fee rate is not valid.");
-
         // Build and sign the original PSBT which pays to the receiver.
         let mut original_psbt = {
             let mut tx_builder = self.wallet.build_tx();
index 5102c0340d3ec3e8a73e7db92bf58700de5b1ee4..ae86dab867e7af02298895fb39b4d4f2878a7c18 100644 (file)
@@ -5,7 +5,7 @@ use bdk_kyoto::{Info, Receiver, UnboundedReceiver, Warning};
 use bdk_message_signer::SignatureFormat;
 #[cfg(feature = "silent-payments")]
 use bdk_sp::encoding::SilentPaymentCode;
-use bdk_wallet::bitcoin::{Address, Network, OutPoint, ScriptBuf};
+use bdk_wallet::bitcoin::{Address, FeeRate, Network, OutPoint, ScriptBuf};
 #[cfg(any(
     feature = "electrum",
     feature = "esplora",
@@ -26,6 +26,13 @@ pub(crate) const DIR_MODE: u32 = 0o700;
 /// Only owner has full read and write access.
 pub(crate) const FILE_MODE: u32 = 0o600;
 
+/// 1 vB is 4 wu and 1 kwu is 1000 wu, so 1 vB is 4/1000 kwu (i.e. 1/250 kwu),
+/// which makes 1 sat/vB equal to 250 sat/kwu.
+const SAT_PER_KWU_PER_SAT_PER_VB: f64 = 250.0;
+
+/// Smallest fee rate `FeeRate` can represent, in sat/vB.
+const MIN_SAT_PER_VB: f64 = 1.0 / SAT_PER_KWU_PER_SAT_PER_VB;
+
 /// Determine if PSBT has final script sigs or witnesses for all unsigned tx inputs.
 #[cfg(any(
     feature = "electrum",
@@ -84,7 +91,7 @@ pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> {
     Ok((user, passwd))
 }
 
-/// Parse a outpoint (Txid:Vout) argument from cli input.
+/// Parse a outpoint (Txid:Vout) argument from input.
 pub(crate) fn parse_outpoint(s: &str) -> Result<OutPoint, Error> {
     Ok(OutPoint::from_str(s)?)
 }
@@ -95,6 +102,39 @@ pub(crate) fn parse_address(address_str: &str) -> Result<Address, Error> {
     Ok(unchecked_address.assume_checked())
 }
 
+/// Parse a fee rate, given in sat/vB, from input.
+///
+/// [`FeeRate`] counts sat/kwu, so fractional rates are kept at 1/250 sat/vB
+/// precision rather than being truncated to a whole sat/vB. A rate that cannot be
+/// represented is rejected instead of silently becoming zero or a default.
+pub(crate) fn parse_fee_rate(s: &str) -> Result<FeeRate, Error> {
+    let sat_vb = f64::from_str(s.trim()).map_err(|_| {
+        Error::Generic(format!(
+            "Invalid fee rate '{s}', expected a number in sat/vB"
+        ))
+    })?;
+
+    if !sat_vb.is_finite() {
+        return Err(Error::Generic(format!(
+            "Invalid fee rate '{s}', must be a finite number of sat/vB"
+        )));
+    }
+    if sat_vb < MIN_SAT_PER_VB {
+        return Err(Error::Generic(format!(
+            "Fee rate '{s}' sat/vB is below the smallest usable rate of {MIN_SAT_PER_VB} sat/vB"
+        )));
+    }
+
+    let sat_kwu = (sat_vb * SAT_PER_KWU_PER_SAT_PER_VB).round();
+    if sat_kwu >= u64::MAX as f64 {
+        return Err(Error::Generic(format!(
+            "Fee rate '{s}' sat/vB is too large to represent"
+        )));
+    }
+
+    Ok(FeeRate::from_sat_per_kwu(sat_kwu as u64))
+}
+
 /// Prepare bdk-cli home directory
 ///
 /// This function is called to check if [`crate::CliOpts`] datadir is set.
@@ -397,111 +437,145 @@ pub(crate) fn write_file_content(path: &Path, contents: &str) -> std::io::Result
     std::fs::write(path, contents)
 }
 
-#[cfg(all(test, unix))]
-mod datadir_file_permissions_tests {
+#[cfg(test)]
+mod tests {
     use super::*;
-    use std::fs;
-    use std::os::unix::fs::PermissionsExt;
-    use tempfile::TempDir;
+    #[cfg(unix)]
+    mod datadir_file_permissions_tests {
+        use super::*;
+        use std::fs;
+        use std::os::unix::fs::PermissionsExt;
+        use tempfile::TempDir;
+
+        fn mode_of(path: &Path) -> u32 {
+            fs::metadata(path).unwrap().permissions().mode() & 0o777
+        }
 
-    fn mode_of(path: &Path) -> u32 {
-        fs::metadata(path).unwrap().permissions().mode() & 0o777
-    }
+        #[test]
+        fn test_write_file_content_creates_an_owner_only_file() {
+            let temp_dir = TempDir::new().unwrap();
+            let path = temp_dir.path().join("config.toml");
 
-    #[test]
-    fn test_write_file_content_creates_an_owner_only_file() {
-        let temp_dir = TempDir::new().unwrap();
-        let path = temp_dir.path().join("config.toml");
+            write_file_content(&path, "tprv").unwrap();
 
-        write_file_content(&path, "tprv").unwrap();
+            assert_eq!(mode_of(&path), FILE_MODE);
+            assert_eq!(fs::read_to_string(&path).unwrap(), "tprv");
+        }
 
-        assert_eq!(mode_of(&path), FILE_MODE);
-        assert_eq!(fs::read_to_string(&path).unwrap(), "tprv");
-    }
+        #[test]
+        fn test_limit_access_hardens_only_exposed_file() {
+            let temp_dir = TempDir::new().unwrap();
+            let exposed = temp_dir.path().join("exposed.toml");
+            let private = temp_dir.path().join("private.toml");
+            fs::write(&exposed, "tprv").unwrap();
+            fs::write(&private, "tprv").unwrap();
+            fs::set_permissions(&exposed, fs::Permissions::from_mode(0o644)).unwrap();
+            fs::set_permissions(&private, fs::Permissions::from_mode(0o400)).unwrap();
+
+            limit_access(&exposed, FILE_MODE).unwrap();
+            limit_access(&private, FILE_MODE).unwrap();
+
+            assert_eq!(mode_of(&exposed), FILE_MODE);
+            assert_eq!(
+                mode_of(&private),
+                0o400,
+                "an owner-only file is not updated"
+            );
+        }
 
-    #[test]
-    fn test_limit_access_hardens_only_exposed_file() {
-        let temp_dir = TempDir::new().unwrap();
-        let exposed = temp_dir.path().join("exposed.toml");
-        let private = temp_dir.path().join("private.toml");
-        fs::write(&exposed, "tprv").unwrap();
-        fs::write(&private, "tprv").unwrap();
-        fs::set_permissions(&exposed, fs::Permissions::from_mode(0o644)).unwrap();
-        fs::set_permissions(&private, fs::Permissions::from_mode(0o400)).unwrap();
-
-        limit_access(&exposed, FILE_MODE).unwrap();
-        limit_access(&private, FILE_MODE).unwrap();
-
-        assert_eq!(mode_of(&exposed), FILE_MODE);
-        assert_eq!(
-            mode_of(&private),
-            0o400,
-            "an owner-only file is not updated"
-        );
-    }
+        #[test]
+        fn test_limit_access_hardens_a_directory() {
+            let temp_dir = TempDir::new().unwrap();
+            let dir = temp_dir.path().join("datadir");
+            fs::create_dir(&dir).unwrap();
+            fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
 
-    #[test]
-    fn test_limit_access_hardens_a_directory() {
-        let temp_dir = TempDir::new().unwrap();
-        let dir = temp_dir.path().join("datadir");
-        fs::create_dir(&dir).unwrap();
-        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
+            limit_access(&dir, DIR_MODE).unwrap();
 
-        limit_access(&dir, DIR_MODE).unwrap();
+            assert_eq!(mode_of(&dir), DIR_MODE);
+        }
 
-        assert_eq!(mode_of(&dir), DIR_MODE);
-    }
+        #[test]
+        fn test_limit_access_ignores_a_missing_path() {
+            let temp_dir = TempDir::new().unwrap();
 
-    #[test]
-    fn test_limit_access_ignores_a_missing_path() {
-        let temp_dir = TempDir::new().unwrap();
+            limit_access(&temp_dir.path().join("absent"), FILE_MODE).unwrap();
+        }
 
-        limit_access(&temp_dir.path().join("absent"), FILE_MODE).unwrap();
-    }
+        #[test]
+        fn test_write_file_content_restricts_a_pre_existing_exposed_file() {
+            let temp_dir = TempDir::new().unwrap();
+            let path = temp_dir.path().join("config.toml");
+            fs::write(&path, "stale").unwrap();
+            fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
 
-    #[test]
-    fn test_write_file_content_restricts_a_pre_existing_exposed_file() {
-        let temp_dir = TempDir::new().unwrap();
-        let path = temp_dir.path().join("config.toml");
-        fs::write(&path, "stale").unwrap();
-        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
+            write_file_content(&path, "tprv").unwrap();
 
-        write_file_content(&path, "tprv").unwrap();
+            assert_eq!(mode_of(&path), FILE_MODE);
+            assert_eq!(fs::read_to_string(&path).unwrap(), "tprv");
+        }
 
-        assert_eq!(mode_of(&path), FILE_MODE);
-        assert_eq!(fs::read_to_string(&path).unwrap(), "tprv");
-    }
+        #[test]
+        fn test_prepare_home_dir_creates_owner_only_datadir() {
+            let temp_dir = TempDir::new().unwrap();
+            let dir = temp_dir.path().join("datadir").join("nested");
 
-    #[test]
-    fn test_prepare_home_dir_creates_owner_only_datadir() {
-        let temp_dir = TempDir::new().unwrap();
-        let dir = temp_dir.path().join("datadir").join("nested");
+            prepare_home_dir(Some(dir.clone())).unwrap();
 
-        prepare_home_dir(Some(dir.clone())).unwrap();
+            assert_eq!(mode_of(&dir), DIR_MODE);
+            assert_eq!(mode_of(dir.parent().unwrap()), DIR_MODE);
+        }
 
-        assert_eq!(mode_of(&dir), DIR_MODE);
-        assert_eq!(mode_of(dir.parent().unwrap()), DIR_MODE);
-    }
+        #[test]
+        fn test_prepare_home_dir_hardens_an_exposed_chosen_datadir() {
+            let temp_dir = TempDir::new().unwrap();
+            let dir = temp_dir.path().join("shared");
+            fs::create_dir(&dir).unwrap();
+            fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
 
-    #[test]
-    fn test_prepare_home_dir_hardens_an_exposed_chosen_datadir() {
-        let temp_dir = TempDir::new().unwrap();
-        let dir = temp_dir.path().join("shared");
-        fs::create_dir(&dir).unwrap();
-        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
+            prepare_home_dir(Some(dir.clone())).unwrap();
 
-        prepare_home_dir(Some(dir.clone())).unwrap();
+            assert_eq!(mode_of(&dir), DIR_MODE);
+        }
 
-        assert_eq!(mode_of(&dir), DIR_MODE);
+        #[test]
+        fn test_prepare_wallet_db_dir_is_owner_only() {
+            let temp_dir = TempDir::new().unwrap();
+            let home = temp_dir.path();
+
+            let dir = prepare_wallet_db_dir(home, "hot").unwrap();
+
+            assert_eq!(mode_of(&dir), DIR_MODE);
+        }
     }
 
     #[test]
-    fn test_prepare_wallet_db_dir_is_owner_only() {
-        let temp_dir = TempDir::new().unwrap();
-        let home = temp_dir.path();
-
-        let dir = prepare_wallet_db_dir(home, "hot").unwrap();
+    fn parses_whole_and_fractional_fee_rates() {
+        let one_sat_vb = FeeRate::from_sat_per_vb(1).unwrap();
+        assert_eq!(parse_fee_rate("1").unwrap(), one_sat_vb);
+        assert_eq!(parse_fee_rate("1.0").unwrap(), one_sat_vb);
+        assert_eq!(parse_fee_rate(" 1 ").unwrap(), one_sat_vb);
+        assert_eq!(
+            parse_fee_rate("2.7").unwrap(),
+            FeeRate::from_sat_per_kwu(675)
+        );
+        assert_eq!(
+            parse_fee_rate("0.004").unwrap(),
+            FeeRate::from_sat_per_kwu(1)
+        );
+        assert_eq!(
+            parse_fee_rate("0.9").unwrap(),
+            FeeRate::from_sat_per_kwu(225)
+        );
+    }
 
-        assert_eq!(mode_of(&dir), DIR_MODE);
+    #[test]
+    fn rejects_fee_rates_that_cannot_be_honoured() {
+        for input in ["0", "-5", "NaN", "inf", "1e30", "abc", ""] {
+            assert!(
+                parse_fee_rate(input).is_err(),
+                "fee rate '{input}' should be rejected"
+            );
+        }
     }
 }
index 3bb0f891a2d2b2f894e99198e322e60d2ed47026..4b678eba06c23bee4d34395d6785e7989facc49c 100644 (file)
@@ -189,6 +189,34 @@ mod test_offline {
         .stderr(predicate::str::contains("Base64 decoding error"));
     }
 
+    /// An invalid `--fee_rate` is rejected up front instead of
+    /// silently becoming a zero fee or the builder default.
+    #[test]
+    fn test_create_tx_rejects_unusable_fee_rates() {
+        let (cli, mut cmd_init) = setup_wallet_config();
+        cmd_init.assert().success();
+
+        for (fee_rate, expected) in [
+            ("0", "below the smallest usable rate"),
+            ("NaN", "must be a finite number"),
+            ("1e30", "too large to represent"),
+            ("abc", "expected a number in sat/vB"),
+        ] {
+            cli.wallet_cmd(&[
+                "--wallet",
+                WALLET_NAME,
+                "create_tx",
+                "--to",
+                RECIPIENT,
+                "--fee_rate",
+                fee_rate,
+            ])
+            .assert()
+            .code(2)
+            .stderr(predicate::str::contains(expected));
+        }
+    }
+
     #[cfg(feature = "message_signer")]
     #[test]
     fn test_sign_message_and_verify_message() {