Skip to content

fix transaction creation input validation and fee rate calculation - #326

Open
tvpeter wants to merge 3 commits into
bitcoindevkit:masterfrom
tvpeter:fix/create-tx-validation
Open

tvpeter wants to merge 3 commits into
bitcoindevkit:masterfrom
tvpeter:fix/create-tx-validation

Conversation

@tvpeter

@tvpeter tvpeter commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR addresses input-validation problems on the transaction-building commands (create_tx, create_sp_tx, bump_fee), transaction fee rate and OP_RETURN data size:

  • Panics on malformed input: create_tx and bump_fee called .unwrap() on Results so they panic (exit 101) instead of an error (exit 1).
  • The create_sp_tx already guarded these paths, but improvements were made to the error type been returned
  • Fee rates: --fee_rate was an f32 cast with as u64, which truncates and saturates, and a None from from_sat_per_vb was silently skipped. Parsing now happens in a value_parser, so bad values are rejected with
    a usage message before a wallet is loaded. Because FeeRate counts sat/kwu, fractional rates keep 1/250 sat/vB precision instead of being truncated .
  • OP_RETURN size: Both --add_data and --add_string document "max 80 bytes" and neither enforced it. This has now been updated to 100_000 bytes and enforced in transaction building.
  • create_dns_tx was had the same fee-rate bug and the same OP_RETURN handling, and has been fixed too.
  • bump_fee --utxos and send_payjoin -f are fixed by the same changes.

Fixes #325

Notes to the reviewers

Changelog notice

  • 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
  • Enforced the documented 100_000 byte limit on --add_data and --add_string OP_RETURN payloads

Checklists

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo fmt and cargo clippy before committing

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

- `--fee_rate` was parsed as `f32` and cast with `as u64`, which is both
truncating and saturating, and when `FeeRate::from_sat_per_vb` returned
None the value was silently dropped. Update parsing fee_rate at the
clap boundary instead and `FeeRate` counts sat/kwu, so fractional
rates keep 1/250 sat/vB precision rather than being truncated, and
anything that cannot be represented is rejected.

- The `--add_data` and `--add_string` length was not checked. The limit
is now 99994 data bytes, Core v30's default `-datacarriersize` of
100_000 minus the 6 bytes the `OP_RETURN` opcode and `OP_PUSHDATA4`
prefix occupy, since Core measures the whole scriptPubKey.
 A unit test pins that arithmetic against `ScriptBuf::new_op_return`.
- update `create_tx`, `create_sp_tx`,` `bump_fee` commands
where `unwrap()` was called on Results and panicked.
- update fee_rate calculations accross create_tx, create_sp_tx
- update OP_RETURN data parsing accross the commands
- add test to check against panics and invalid fee_rates
- update fee_rate in dns module and payjoin
- update error propagation in dns and payjoin
- update CHANGELOG
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.41975% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.75%. Comparing base (5afbc8a) to head (4a69235).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
src/handlers/offline.rs 50.00% 6 Missing ⚠️
src/handlers/dns/mod.rs 20.00% 4 Missing ⚠️
src/handlers/payjoin/mod.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #326      +/-   ##
==========================================
+ Coverage   57.78%   58.75%   +0.97%     
==========================================
  Files          22       22              
  Lines        3733     3773      +40     
==========================================
+ Hits         2157     2217      +60     
+ Misses       1576     1556      -20     
Flag Coverage Δ
rust 58.75% <86.41%> (+0.97%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tvpeter tvpeter changed the title Fix transaction creation input validation and fee rate calculation fix transaction creation input validation and fee rate calculation Sep 22, 2026

@vadim-anfv vadim-anfv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

MAX_OP_RETURN_BYTES (99_994) counts the OP_RETURN scriptPubKey only, so create_tx builds transactions over the 100_000 vB standard tx size limit, which no default-policy node relays. The output alone is 100_013 vB (8 value + 5 length prefix + 100_000 script), before any input, change or header.

A repro, not a test I'm suggesting you merge:

/// `--add_string` accepts a payload of up to `MAX_OP_RETURN_BYTES` (99_994),
/// but the resulting transaction is over the 100_000 vB standardness limit,
/// so no default-policy node relays it.
#[test]
fn test_max_op_return_payload_fits_the_standard_tx_size() {
    let (cli, mut cmd_init, env) = setup_online_wallet();
    cmd_init.assert().success();
    fund_and_sync_wallet(&cli, &env);

    let data = "A".repeat(99_994);
    let to = format!("{RECIPIENT}:15000");
    let args = ["create_tx", "--to", &to, "--add_string", &data];
    let psbt = run_wallet_json(&cli, &args)["psbt"].as_str().unwrap().to_owned();

    // Signing it would mean passing ~200 KB of base64 as an argument, and the
    // unsigned tx is enough here: the signed one is only bigger.
    let tx = bdk_wallet::bitcoin::Psbt::from_str(&psbt).unwrap().unsigned_tx;
    let result = env.rpc_client().test_mempool_accept(&[&tx]).unwrap();

    let reason = result[0].reject_reason.as_deref();
    assert!(
        reason != Some("tx-size"),
        "node rejected the {} vB tx built at the documented limit: {}",
        tx.vsize(),
        reason.unwrap_or("accepted")
    );
}
$ cargo test --all-features --test cli test_max_op_return

test ...::test_max_op_return_payload_fits_the_standard_tx_size ... FAILED

node rejected the 100150 vB tx built at the documented limit: tx-size

That tx is 150 vB over the limit with one input, a recipient and change, so the usable payload is at most ~99_844 here, less with more inputs. the_largest_allowed_payload_fits_the_script_limit locks in the same unusable size: a 100_000 byte script never fits a standard tx.

Non-blocking: master enforced no limit here at all, so this is an improvement either way.

@notmandatory

Copy link
Copy Markdown
Member

MAX_OP_RETURN_BYTES (99_994) counts the OP_RETURN scriptPubKey only, so create_tx builds transactions over the 100_000 vB standard tx size limit, which no default-policy node relays. The output alone is 100_013 vB (8 value + 5 length prefix + 100_000 script), before any input, change or header.

Is this something that needs to be checked and enforced in the bdk_tx crate?

@vadim-anfv

Copy link
Copy Markdown
Collaborator

Is this something that needs to be checked and enforced in the bdk_tx crate?

bdk-cli doesn't use bdk_tx, transactions are built with TxBuilder from bdk_wallet.

Is the plan to move transaction building to bdk_tx, in bdk-cli in particular?
If so, the tx size check belongs there.

@notmandatory

Copy link
Copy Markdown
Member

Is the plan to move transaction building to bdk_tx, in bdk-cli in particular? If so, the tx size check belongs there.

The plan is to transition bdk_wallet to depreciate TxBuilder and use bdk_tx via Wallet::create_psbt (see bitcoindevkit/bdk_wallet#516). So I'd suggest adding the check to bdk-tx and updating bdk-cli to enable the Wallet::create_psbt experimental feature and using it. But that all will need to be done in new PRs.

@vadim-anfv

Copy link
Copy Markdown
Collaborator

The plan is to transition bdk_wallet to depreciate TxBuilder and use bdk_tx via Wallet::create_psbt (see bitcoindevkit/bdk_wallet#516). So I'd suggest adding the check to bdk-tx and updating bdk-cli to enable the Wallet::create_psbt experimental feature and using it. But that all will need to be done in new PRs.

Two things here.

  1. Moving to Wallet::create_psbt: it is in bdk_wallet 3.2.0, so we can pick it up now. I'll open an issue for it.

  2. The size check: 100_000 vB is default relay policy, not consensus, and miners do accept larger txs out of band, so I'd rather bdk_tx didn't refuse to build such a tx. I'd keep the check closer to the user, where refusing or warning is a call the app can make. create_psbt is also early for it: an unsigned tx can be under the limit and go over it once the witnesses are there, so the check belongs after signing.

@tvpeter tvpeter self-assigned this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

create_tx panics on malformed input instead of returning an error; fee-rate parsing silently truncates or falls back

3 participants