]> Untitled Git - bdk-cli/commitdiff
fix(create_tx,bump_fee): reject txs over the standard weight limit
authorVihiga Tyonum <withtvpeter@gmail.com>
Mon, 19 Oct 2026 19:09:25 +0000 (20:09 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Wed, 4 Nov 2026 15:39:53 +0000 (16:39 +0100)
create_tx` could build transactions over Bitcoin Core's 400,000 wu
`MAX_STANDARD_TX_WEIGHT`, which no default node relays. The `--add_data`
and `--add_string` docs claimed an 80 byte limit that was never enforced
and is no longer Core's default. These changes:

- Enforce 400k WU limit across `create_tx`, `create_dns_tx`, `bump_fee`,
  and `create_sp_tx`.
- Estimate PSBT weight using `max_weight_to_satisfy` per input for
  unsigned flows (`create_tx`, `create_dns_tx`, `bump_fee`, `create_sp_tx`).
- Update `--add_data` and `--add_string` docs to remove references to
  the deprecated 80-byte data limit. Bitcoin Core v30+ evaluates weight
  limits prior to data carrier limits.

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

index eca00a1696b68b26d19c769d60b9a6c7b2340ef8..7d0bdf59cc0e4199b91430bb13f546e647e4f6d8 100644 (file)
@@ -20,6 +20,7 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details.
 - Fixed `compile` silently accepting placeholder keys/hashes instead of validating them; added `--placeholders` to opt back into the old permissive behavior
 - Fixed `compile` panicking instead of returning an error when a key is valid on its own but the wrong shape for the requested script type (e.g. an x-only key under `sh`/`wsh`/`sh-wsh`, or an uncompressed key under `tr`)
 - Updated dependencies to resolve RUSTSEC advisories in rustls, rustls-webpki, webpki-roots, quinn-proto, h2 and zstd-sys; bumped async-hwi to 0.0.35.
+- Fixed `create_tx`, `create_dns_tx`, `create_sp_tx` and `bump_fee` building transactions over Bitcoin Core's 400,000 wu (100,000 vB) standard weight limit, which no default node relays
 
 ## [4.0.0]
 
index 76b31fd0f2bc6bbba6043eae1e2ad5b2aefaaecc..04d1ce6bae7ede640373ecdcff07eba983efb82c 100644 (file)
@@ -1,6 +1,6 @@
 use bdk_wallet::bitcoin::hex::HexToBytesError;
 use bdk_wallet::bitcoin::psbt::ExtractTxError;
-use bdk_wallet::bitcoin::{base64, consensus};
+use bdk_wallet::bitcoin::{Transaction, Weight, base64, consensus};
 use thiserror::Error;
 
 #[derive(Debug, Error)]
@@ -67,6 +67,15 @@ pub enum BDKCliError {
     #[error("Miniscript compiler error: {0}")]
     MiniscriptCompilerError(#[from] bdk_wallet::miniscript::policy::compiler::CompilerError),
 
+    #[error(
+        "Transaction can weigh up to {weight:#} ({} vB) once signed, over the {:#} ({} vB) \
+         standard limit, so Bitcoin Core nodes will not relay it",
+        weight.to_vbytes_ceil(),
+        Transaction::MAX_STANDARD_WEIGHT,
+        Transaction::MAX_STANDARD_WEIGHT.to_vbytes_ceil()
+    )]
+    NonStandardTxWeight { weight: Weight },
+
     #[error("ParseError: {0}")]
     ParseError(#[from] bdk_wallet::bitcoin::address::ParseError),
 
index ca83991de307ca06ac113827bda2d6686d45cd3d..e1c5b8df0a1b0b533d7eb4d0697056db8c130ca6 100644 (file)
@@ -7,8 +7,8 @@ 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_fee_rate, parse_op_return_base64, parse_op_return_string,
-    parse_outpoint, parse_recipient,
+    check_standard_weight, max_signed_weight, parse_dns_recipient, parse_fee_rate,
+    parse_op_return_base64, parse_op_return_string, parse_outpoint, parse_recipient,
 };
 use bdk_wallet::KeychainKind;
 use bdk_wallet::bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, Sequence, script::PushBytesBuf};
@@ -158,6 +158,7 @@ impl AsyncAppCommand<AppContext<OfflineOperations<'_>>> for CreateDnsTxCommand {
         }
 
         let psbt = tx_builder.finish()?;
+        check_standard_weight(max_signed_weight(ctx.state.wallet, &psbt)?)?;
         Ok(PsbtResult::new(&psbt, Some(false)))
     }
 }
index 20a2082e5a325a3fae067c76be7482f78fd1dc9a..80a6de47683bba58e9610d5c16bc72ca332bab06 100644 (file)
@@ -7,8 +7,8 @@ use crate::utils::types::{
     UnspentDetails,
 };
 use crate::utils::{
-    parse_address, parse_fee_rate, parse_op_return_base64, parse_op_return_string, parse_outpoint,
-    parse_recipient,
+    check_standard_weight, max_signed_weight, parse_address, parse_fee_rate,
+    parse_op_return_base64, parse_op_return_string, parse_outpoint, parse_recipient,
 };
 use bdk_wallet::bitcoin::base64::Engine;
 use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD;
@@ -230,7 +230,7 @@ pub struct CreateTxCommand {
     #[arg(env = "INT_POLICY", long = "internal_policy")]
     pub internal_policy: Option<String>,
 
-    /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes)
+    /// Optionally create an OP_RETURN output containing given String in utf8 encoding. The transaction must stay within Bitcoin Core's 100,000 vB standard size limit.
     #[arg(
         env = "ADD_STRING",
         long = "add_string",
@@ -240,7 +240,7 @@ pub struct CreateTxCommand {
     )]
     pub add_string: Option<PushBytesBuf>,
 
-    /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes)
+    /// Optionally create an OP_RETURN output containing given base64 encoded String. The transaction must stay within Bitcoin Core's 100,000 vB standard size limit.
     #[arg(
         env = "ADD_DATA",
         long = "add_data",
@@ -317,6 +317,8 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateTxCommand {
 
         let psbt = tx_builder.finish()?;
 
+        check_standard_weight(max_signed_weight(ctx.state.wallet, &psbt)?)?;
+
         Ok(PsbtResult::new(&psbt, Some(false)))
     }
 }
@@ -353,7 +355,7 @@ pub struct CreateSpTxCommand {
     /// Selects which policy should be used to satisfy the internal descriptor.
     #[arg(env = "INT_POLICY", long = "internal_policy")]
     pub internal_policy: Option<String>,
-    /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes)
+    /// Optionally create an OP_RETURN output containing given String in utf8 encoding. The transaction must stay within Bitcoin Core's 100,000 vB standard size limit.
     #[arg(
         env = "ADD_STRING",
         long = "add_string",
@@ -362,7 +364,7 @@ pub struct CreateSpTxCommand {
         value_parser = parse_op_return_string,
     )]
     pub add_string: Option<PushBytesBuf>,
-    /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes)
+    /// Optionally create an OP_RETURN output containing given base64 encoded String. The transaction must stay within Bitcoin Core's 100,000 vB standard size limit.
     #[arg(
         env = "ADD_DATA",
         long = "add_data",
@@ -466,6 +468,8 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for CreateSpTxCommand {
 
         let mut psbt = tx_builder.finish()?;
 
+        check_standard_weight(max_signed_weight(ctx.state.wallet, &psbt)?)?;
+
         let unsigned_psbt = psbt.clone();
 
         let finalized = ctx.state.wallet.sign(&mut psbt, SignOptions::default())?;
@@ -591,7 +595,7 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for BumpFeeCommand {
 
         let psbt = tx_builder.finish()?;
 
-        // let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize());
+        check_standard_weight(max_signed_weight(wallet, &psbt)?)?;
 
         Ok(PsbtResult::new(&psbt, Some(false)))
     }
index 775bb93c103453249a1d029dfb51b1009d75da3a..1e3cbbe72588827e37f26845332b124f306f94c0 100644 (file)
@@ -1,3 +1,4 @@
+use crate::commands::OfflineWalletSubCommand;
 use crate::{commands::WalletOpts, config::WalletConfig, error::BDKCliError as Error};
 #[cfg(feature = "cbf")]
 use bdk_kyoto::{Info, Receiver, UnboundedReceiver, Warning};
@@ -5,20 +6,20 @@ 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, FeeRate, Network, OutPoint, ScriptBuf};
-use bdk_wallet::bitcoin::{
-    base64::{Engine, prelude::BASE64_STANDARD},
-    script::PushBytesBuf,
-};
+use bdk_wallet::Wallet;
 #[cfg(any(
     feature = "electrum",
     feature = "esplora",
     feature = "cbf",
     feature = "rpc"
 ))]
-use bdk_wallet::{WalletEvent, bitcoin::Psbt};
-
-use crate::commands::OfflineWalletSubCommand;
+use bdk_wallet::WalletEvent;
+use bdk_wallet::bitcoin::base64::Engine;
+use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD;
+use bdk_wallet::bitcoin::script::PushBytesBuf;
+use bdk_wallet::bitcoin::{
+    Address, FeeRate, Network, OutPoint, Psbt, ScriptBuf, Transaction, Weight,
+};
 use std::{
     path::{Path, PathBuf},
     str::FromStr,
@@ -149,6 +150,37 @@ pub(crate) fn parse_op_return_base64(s: &str) -> Result<PushBytesBuf, Error> {
     Ok(PushBytesBuf::try_from(BASE64_STANDARD.decode(s)?)?)
 }
 
+/// Reject a transaction over Bitcoin Core's standard weight limit (`tx-size`).
+///
+/// This also bounds `OP_RETURN` data: Core derives its default `-datacarriersize`
+/// from this limit, so its `datacarrier` check cannot reject a transaction that
+/// passes this one.
+pub(crate) fn check_standard_weight(weight: Weight) -> Result<(), Error> {
+    if weight > Transaction::MAX_STANDARD_WEIGHT {
+        return Err(Error::NonStandardTxWeight { weight });
+    }
+    Ok(())
+}
+
+/// The most `psbt` can weigh once its inputs are signed.
+///
+/// An unsigned transaction can be under the standard limit and over it once
+/// signed, so each wallet input counts the largest witness its descriptor can
+/// need, as coin selection does. Inputs the wallet does not own count none.
+pub(crate) fn max_signed_weight(wallet: &Wallet, psbt: &Psbt) -> Result<Weight, Error> {
+    let tx = &psbt.unsigned_tx;
+    // The unsigned transaction is serialized without the segwit marker and flag
+    // (2 wu) or the witness count of each input (1 wu).
+    let mut weight = tx.weight() + Weight::from_wu(2 + tx.input.len() as u64);
+    for txin in &tx.input {
+        // Not `get_utxo`: the inputs of a fee bump are already spent.
+        if let Some(((keychain, _), _)) = wallet.spk_index().txout(txin.previous_output) {
+            weight += wallet.public_descriptor(keychain).max_weight_to_satisfy()?;
+        }
+    }
+    Ok(weight)
+}
+
 /// Prepare bdk-cli home directory
 ///
 /// This function is called to check if [`crate::CliOpts`] datadir is set.
@@ -600,6 +632,104 @@ mod tests {
         assert!(parse_op_return_base64("!!!not-base64!!!").is_err());
     }
 
+    #[test]
+    fn standard_weight_limit_is_inclusive() {
+        let max = Transaction::MAX_STANDARD_WEIGHT;
+        assert!(check_standard_weight(max).is_ok());
+        assert!(check_standard_weight(max + Weight::from_wu(1)).is_err());
+    }
+
+    mod max_signed_weight_tests {
+        use super::*;
+        use bdk_wallet::bitcoin::hashes::Hash;
+        use bdk_wallet::bitcoin::{Amount, TxIn, TxOut, Txid, absolute, transaction};
+        use bdk_wallet::{KeychainKind, SignOptions};
+
+        const TPRV: &str = "tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L";
+
+        /// A wallet for `template`, with `KEY` replaced by each keychain's key,
+        /// holding `utxos` unconfirmed coins.
+        fn funded_wallet(template: &str, utxos: usize) -> Wallet {
+            let desc = |keychain: u32| template.replace("KEY", &format!("{TPRV}/{keychain}/*"));
+            let mut wallet = Wallet::create(desc(0), desc(1))
+                .network(Network::Regtest)
+                .create_wallet_no_persist()
+                .unwrap();
+            let output = (0..utxos)
+                .map(|_| TxOut {
+                    value: Amount::from_sat(100_000),
+                    script_pubkey: wallet
+                        .reveal_next_address(KeychainKind::External)
+                        .script_pubkey(),
+                })
+                .collect();
+            let funding = Transaction {
+                version: transaction::Version::TWO,
+                lock_time: absolute::LockTime::ZERO,
+                input: vec![TxIn {
+                    previous_output: OutPoint::new(Txid::from_byte_array([1; 32]), 0),
+                    ..Default::default()
+                }],
+                output,
+            };
+            wallet.apply_unconfirmed_txs([(funding, 0)]);
+            wallet
+        }
+
+        /// Sign `psbt` and check `estimate` bounds the result.
+        ///
+        /// It may overshoot by the segwit marker, flag and witness counts a
+        /// legacy transaction does not have, and per input by the byte or two a
+        /// signature is shorter than assumed, at 4 wu each in a scriptSig.
+        fn assert_bounds(wallet: &Wallet, mut psbt: Psbt, estimate: Weight) -> Transaction {
+            assert!(wallet.sign(&mut psbt, SignOptions::default()).unwrap());
+            let tx = psbt.extract_tx().unwrap();
+            let slack = Weight::from_wu(2 + 9 * tx.input.len() as u64);
+            assert!(
+                tx.weight() <= estimate && estimate - tx.weight() <= slack,
+                "signed weight {} vs estimate {estimate}",
+                tx.weight()
+            );
+            tx
+        }
+
+        #[test]
+        fn bounds_the_signed_weight_for_each_script_type() {
+            for template in ["tr(KEY)", "wpkh(KEY)", "sh(wpkh(KEY))", "pkh(KEY)"] {
+                let mut wallet = funded_wallet(template, 3);
+                let to = wallet.peek_address(KeychainKind::External, 100);
+                let mut builder = wallet.build_tx();
+                builder.drain_wallet().drain_to(to.script_pubkey());
+                let psbt = builder.finish().unwrap();
+
+                let estimate = max_signed_weight(&wallet, &psbt).unwrap();
+                assert_bounds(&wallet, psbt, estimate);
+            }
+        }
+
+        #[test]
+        fn bounds_a_fee_bump_spending_the_same_inputs() {
+            let mut wallet = funded_wallet("wpkh(KEY)", 2);
+            let to = wallet.peek_address(KeychainKind::External, 100);
+            let mut builder = wallet.build_tx();
+            builder
+                .add_recipient(to.script_pubkey(), Amount::from_sat(150_000))
+                .fee_rate(FeeRate::from_sat_per_vb(1).unwrap());
+            let psbt = builder.finish().unwrap();
+            let estimate = max_signed_weight(&wallet, &psbt).unwrap();
+            let original = assert_bounds(&wallet, psbt, estimate);
+            let txid = original.compute_txid();
+            wallet.apply_unconfirmed_txs([(original, 1)]);
+
+            // The inputs are now spent, so they are no longer wallet UTXOs.
+            let mut builder = wallet.build_fee_bump(txid).unwrap();
+            builder.fee_rate(FeeRate::from_sat_per_vb(10).unwrap());
+            let psbt = builder.finish().unwrap();
+            let estimate = max_signed_weight(&wallet, &psbt).unwrap();
+            assert_bounds(&wallet, psbt, estimate);
+        }
+    }
+
     #[test]
     fn rejects_fee_rates_that_cannot_be_honoured() {
         for input in ["0", "-5", "NaN", "inf", "1e30", "abc", ""] {
index dc2399f2ab548d28012a501252320e1e2c06c39b..d7901ce3f0fa9014372b48cb07eff2c9142f78bb 100644 (file)
@@ -552,6 +552,37 @@ mod test_online {
         );
     }
 
+    // An OP_RETURN payload is limited only by the transaction it is in having
+    // to stay within Bitcoin Core's 400_000 wu standard weight. A payload pushing
+    //it over must fail when building, not at broadcast after signing.
+    #[test]
+    fn test_create_tx_op_return_within_standard_weight() {
+        let (cli, mut cmd_init, env) = setup_online_wallet();
+        cmd_init.assert().success();
+        fund_and_sync_wallet(&cli, &env);
+
+        let to = format!("{RECIPIENT}:15000");
+
+        let data = "A".repeat(99_000);
+        run_wallet_json(&cli, &["create_tx", "--to", &to, "--add_string", &data]);
+
+        let too_large = "A".repeat(99_994);
+        cli.wallet_cmd(&[
+            "--wallet",
+            WALLET_NAME,
+            "create_tx",
+            "--to",
+            &to,
+            "--add_string",
+            &too_large,
+        ])
+        .assert()
+        .code(1)
+        .stderr(predicates::str::contains(
+            "over the 400000 wu (100000 vB) standard limit",
+        ));
+    }
+
     // `create_tx --utxos` forces a specific UTXO to be spent;
     // `--unspendable`excludes one. Funds two UTXOs so the selection is actually observable.
     #[test]
@@ -830,6 +861,22 @@ mod test_online {
                 sp_out.script_pubkey.is_p2tr(),
                 "SP recipient output should be P2TR"
             );
+
+            let too_large = "A".repeat(99_994);
+            cli.wallet_cmd(&[
+                "--wallet",
+                WALLET,
+                "create_sp_tx",
+                "--to-sp",
+                &format!("{sp_code}:20000"),
+                "--add_string",
+                &too_large,
+            ])
+            .assert()
+            .code(1)
+            .stderr(predicates::str::contains(
+                "over the 400000 wu (100000 vB) standard limit",
+            ));
         }
     }