fix(wallet): reject txs exceeding MAX_STANDARD_TX_WEIGHT - #544
cestercian wants to merge 3 commits into
Conversation
j-kon
left a comment
There was a problem hiding this comment.
Thanks for tackling issue #543! Preventing wallets from building transactions that standard mempools will inevitably reject is an important safety improvement.
While the high-level intent and the tests for local P2WPKH drains are solid, our line-by-line review and test verification identified three concrete issues in the implementation that need to be addressed before this is ready to merge:
-
Foreign / planned input satisfaction weights are dropped in
create_psbt:
Insrc/wallet/mod.rs:3488-3494,create_psbtresolves satisfaction weights solely viaself.satisfaction_weight_for_outpoint. For foreign or planned inputs, this returnsWeight::ZERO, meaning the standardness check underestimates transactions containing external or planned inputs. -
Witness serialization discrepancies in
check_max_standard_tx_weight:
Insrc/wallet/mod.rs:2995-3000:-
Pure legacy transactions (+2 WU): Any non-zero satisfaction weight (including legacy
scriptSigsatisfaction like 428 WU for P2PKH) triggers+ 2 WUfor SegWit marker/flag, which are never serialized. -
SegWit & Mixed transactions (-1 WU per input): When an unsigned transaction has empty witnesses,
tx.weight()evaluates as a legacy transaction with zero witness bytes. Under BIP 141 extended serialization, every input requires a 1-byte witness stack length varint (0x00). Miniscript'smax_weight_to_satisfy()returns the delta fromTxIn::default().segwit_weight()(which already assumed that 1 byte was present). Adding only+ 2 WUfails to account for this 1 byte per input ($K$ WU total), underestimating multi-input SegWit and mixed transactions.
-
Pure legacy transactions (+2 WU): Any non-zero satisfaction weight (including legacy
-
Unchecked arithmetic leads to debug panic and release-mode validation bypass:
Insrc/wallet/mod.rs:2994-3000,satisfaction_weights.into_iter().sum()andtx.weight() + satisfaction + segwit_markeruse uncheckedWeightaddition. When foreign UTXOs have large satisfaction weights:- In debug builds, it panics with
attempt to add with overflowinstead of returningTxWeightLimitExceeded. - In release builds (
overflow-checks = false), the addition silently wraps around modulo$2^{64}$ . A transaction with an astronomical satisfaction weight wraps to a small number (e.g. 129 WU), passingweight <= MAX_STANDARD_TX_WEIGHTand completely bypassing the policy check.
- In debug builds, it panics with
Detailed suggestions and minimal reproductions are provided in the inline comments below.
| .input | ||
| .iter() | ||
| .map(|txin| self.satisfaction_weight_for_outpoint(txin.previous_output)); | ||
| check_max_standard_tx_weight(&psbt.unsigned_tx, satisfaction_weights) |
There was a problem hiding this comment.
In create_psbt, satisfaction weights are resolved solely via self.satisfaction_weight_for_outpoint(txin.previous_output):
fn satisfaction_weight_for_outpoint(&self, outpoint: OutPoint) -> Weight {
self.get_utxo(outpoint)
.and_then(|utxo| {
self.public_descriptor(utxo.keychain)
.max_weight_to_satisfy()
.ok()
})
.unwrap_or(Weight::ZERO)
}For foreign or planned inputs (e.g. added via PsbtParams::add_planned_input or coin-selected from external sources), self.get_utxo returns None, so their satisfaction weight evaluates to Weight::ZERO.
As a result, create_psbt completely discounts satisfaction weights for non-local inputs. A PSBT spending foreign/planned inputs whose signed size would exceed MAX_STANDARD_TX_WEIGHT passes with Ok, whereas the equivalent transaction in create_tx correctly fails with TxWeightLimitExceeded.
Reproduction:
Adding a planned/foreign input with satisfaction weight exceeding 400,000 WU to PsbtParams results in wallet.create_psbt(params) returning Ok(psbt) instead of Err(CreatePsbtError::TxWeightLimitExceeded).
Suggestion:
Consider retrieving the planned satisfaction weights from the selection candidates (e.g., selection.inputs(), where bdk_tx::Input::satisfaction_weight() is already tracked) rather than querying self.satisfaction_weight_for_outpoint.
There was a problem hiding this comment.
create_psbt now takes satisfaction_weight() from the selected inputs, so planned/foreign ones are not zeroed out anymore.
| ) -> Result<(), (Weight, Weight)> { | ||
| let satisfaction: Weight = satisfaction_weights.into_iter().sum(); | ||
| let segwit_marker = if satisfaction > Weight::ZERO { | ||
| Weight::from_wu(2) |
There was a problem hiding this comment.
The witness overhead calculation has two accounting discrepancies with BIP 141 serialization:
-
Pure legacy overestimation (+2 WU):
satisfaction > Weight::ZEROis true for legacy transactions becausescriptSigsatisfaction weights are positive (e.g., 428 WU for P2PKH). This adds 2 WU for a SegWit marker/flag (0x0001) that is never serialized in legacy transactions. -
SegWit & Mixed underestimation (-1 WU per input):
Whentxis unsigned, all input witnesses are empty, sotx.weight()evaluates as a legacy transaction with zero witness bytes.
Under BIP 141 extended serialization, every input requires a 1-byte witness stack length varint (0x00). Miniscript'smax_weight_to_satisfy()returnstxin.segwit_weight() - TxIn::default().segwit_weight(), whereTxIn::default().segwit_weight()already assumed that 1-byte empty witness was present. Adding only+ 2 WUmisses 1 WU for every input ($K$ WU total).
On transactions with many inputs (such as UTXO consolidation), this systematically underestimates the worst-case signed weight by
| Case | Inputs ( |
Unsigned | Satisfaction | PR Estimate | Actual Worst-Case Signed | Discrepancy |
|---|---|---|---|---|---|---|
| Pure P2PKH | 1 | 340 WU | 428 WU | 770 WU | 768 WU | +2 WU (overcounted) |
| Mixed (1 WPKH + 2 PKH) | 3 | 656 WU | 963 WU | 1,621 WU | 1,624 WU | -3 WU (undercounted) |
| Pure P2WPKH | 100 | 16,564 WU | 10,700 WU | 27,266 WU | 27,366 WU | -100 WU (undercounted) |
Suggestion:
Consider differentiating transactions where witnesses will be present:
- If no inputs have witness satisfaction (pure legacy): witness overhead is
0 WU. - If any input has witness satisfaction (SegWit): witness overhead is
2 WU + (1 WU * tx.input.len()).
There was a problem hiding this comment.
Yeah — pure legacy adds no marker now; if any input has a witness, it's 2 WU plus 1 per input. Checked against 768 / 1,624 / 27,366 from your table.
| tx: &Transaction, | ||
| satisfaction_weights: impl IntoIterator<Item = Weight>, | ||
| ) -> Result<(), (Weight, Weight)> { | ||
| let satisfaction: Weight = satisfaction_weights.into_iter().sum(); |
There was a problem hiding this comment.
bitcoin::Weight wraps u64 and implements Add and Sum via primitive integer arithmetic without overflow protection. If foreign inputs have large caller-supplied satisfaction weights (via TxBuilder::add_foreign_utxo), this arithmetic overflows:
- In debug builds (
cargo test),tx.weight() + satisfactionpanics withattempt to add with overflowinstead of returningCreateTxError::TxWeightLimitExceeded. - In release builds (where
overflow-checks = false), the addition silently wraps around modulo$2^{64}$ . A transaction with an astronomical satisfaction weight wraps to a small number, passingweight <= MAX_STANDARD_TX_WEIGHTand completely bypassing the check.
Reproduction:
let mut builder = wallet.build_tx();
builder.add_foreign_utxo(outpoint, psbt_in, Weight::from_wu(u64::MAX - 200)).unwrap();
builder.manually_selected_only();
builder.fee_absolute(Amount::from_sat(1000));
builder.drain_wallet().drain_to(addr.script_pubkey());
let res = builder.finish();- Under
cargo test, this panics withattempt to add with overflowat line 3000. - Under
cargo test --release, unsigned weight 328 WU +$(2^{64} - 201)\text{ WU} + 2\text{ WU}$ wraps to 129 WU, returningOk(psbt)and bypassing the standardness limit.
Suggestion:
Use checked arithmetic (satisfaction_weights.into_iter().try_fold(Weight::ZERO, Weight::checked_add) and checked_add), and return Err((Weight::MAX, limit)) if an overflow occurs.
There was a problem hiding this comment.
Switched to checked_add. Overflow comes back as TxWeightLimitExceeded with Weight::MAX instead of panicking or wrapping past the limit.
|
Thanks for the review — addressed all three. Planned/foreign inputs keep their satisfaction weight from the selection, witness overhead now splits pure legacy (0) vs segwit/mixed (2 + 1 per input), and a huge foreign satisfaction returns TxWeightLimitExceeded instead of panicking or wrapping. The 1,500-input drains still fail the check; added coverage for the planned-input case, the overhead table, and the overflow path. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #544 +/- ##
==========================================
- Coverage 81.91% 81.43% -0.48%
==========================================
Files 25 25
Lines 6535 6448 -87
Branches 302 313 +11
==========================================
- Hits 5353 5251 -102
- Misses 1075 1089 +14
- Partials 107 108 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Follow-up from re-review: I reproduced a case through the public At the standardness boundary this becomes observable: an actual 400,000 WU legacy transaction is estimated as 400,003 WU and rejected with Could we derive witness serialization from the prevout/redeem script instead of using the presence of |
|
Please rebase and sign all your commits. Take a look at contributing guide here |
793fd44 to
d1c0686
Compare
|
yeah makes sense. spends_with_witness now looks at the prevout / redeem script instead of whether witness_utxo is set, and i rebased + signed the commits. |
create_tx and create_psbt assembled transactions without checking bitcoin::policy::MAX_STANDARD_TX_WEIGHT (400_000 WU). A drain of many small UTXOs could therefore produce a fully signed PSBT that every standard mempool rejects, while sign still returned Ok(true). After the unsigned tx is assembled, estimate the signed weight (unsigned weight plus each input's satisfaction weight) and return CreateTxError::TxWeightLimitExceeded / CreatePsbtError::TxWeightLimitExceeded when it exceeds the limit. Fixes bitcoindevkit#543
create_psbt ignored satisfaction weights on foreign and planned inputs, the BIP 141 witness overhead was off by a marker or one unit per input, and unchecked Weight addition could panic or wrap past the limit. Count satisfaction from the selected input, add witness overhead only when an input actually has a witness, and use checked addition so an overflow is TxWeightLimitExceeded.
spends_with_witness treated a populated witness_utxo as proof of witness serialization. A legacy P2PKH foreign input can carry both UTXO fields; that misclassification adds 3 WU at the standardness boundary and rejects a 400_000 WU legacy spend. Native witness programs come from the prevout. Nested segwit comes from a P2SH redeem script that is itself a witness program.
d1c0686 to
ce8309e
Compare
|
rebased on master and pushed the weight fixes. satisfaction weight comes from the selected inputs now, segwit overhead only gets counted when an input is actually segwit, and the sums are checked so overflow returns TxWeightLimitExceeded. |
Description
Neither
create_txnorcreate_psbtchecked the assembled transaction against Bitcoin's standardness weight limit (bitcoin::policy::MAX_STANDARD_TX_WEIGHT, 400_000 WU). Dust was already rejected (OutputBelowDustLimit); weight was not.A drain of many small UTXOs could therefore produce a fully signed PSBT over 400k WU. Every standardness-enforcing mempool rejects that transaction, but
signstill returnedOk(true)— a misleading success.Root cause: After coin selection the unsigned transaction is assembled with empty witnesses.
Transaction::weight()on that value undercounts the final signed size by each input's satisfaction (witness / scriptSig) weight. No later check compared the estimated signed weight to the standardness limit.Fix: After the unsigned tx is assembled, estimate the signed weight (
tx.weight()plus each input's satisfaction weight, plus the 2-WU segwit marker when witnesses will be present) and reject when it exceedsMAX_STANDARD_TX_WEIGHT.CreateTxError::TxWeightLimitExceeded { weight, limit }(stablecreate_txpath)CreatePsbtError::TxWeightLimitExceeded { weight, limit }(unstablecreate_psbt/create_psbt_from_selectorpath, so RBF is covered too)Tests:
create_psbtdrain regressionFixes #543
Notes to the reviewers
CreateTxErroris exhaustive, so downstreammatches must handle the new variant.CreatePsbtErroris already#[non_exhaustive].tx.weight(). Checking only the unsigned weight would miss the 1,500-P2WPKH-input case (~246k WU unsigned vs ~408k WU signed).create_txcome from theWeightedUtxos used in coin selection (including foreign UTXOs). Thecreate_psbtpath looks up local descriptors viamax_weight_to_satisfy.Changelog notice
create_tx/create_psbtproducing unrelayable transactions overMAX_STANDARD_TX_WEIGHTby returningTxWeightLimitExceeded.Before submitting
Assistance: implementation drafted with Cursor (cloud agent); author is Cestercian. Commits are SSH-signed; GitHub may show Unverified if the cloud-agent SSH signing key is not registered on the account as a signing key.