Skip to content

Commit e98cb10

Browse files
evanlinjinclaude
andcommitted
feat!: add Target::max_weight and enforce it in selection
Add an optional `Target::max_weight` cap (in WU) on the resulting transaction — e.g. for TRUC/BIP-431 packages capped at 10,000/1,000 vB. It's a feasibility constraint mirroring the value target: value is a lower bound, `max_weight` an upper bound. Enforced only where a selection is committed: - `is_within_max_weight`: the anti-monotone weight predicate, kept separate from the monotone value-only `is_target_met` (whose docs disclaim the cap). - `select_until_target_met` now errors with `SelectError::MaxWeightExceeded` instead of silently returning an over-cap selection. - `LowestFee`: refuse a change output that would bust the cap, reject any over-cap selection, and hard-prune subtrees whose lightest (no-drain) form already busts it. A cap-agnostic `fee_score` feeds the bound's estimates so they stay admissible. Because `LowestFee` decides change economically, the existing bound proof survives the cap, so the hard-prune alone suffices. `is_selection_possible` stays a value-only check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fffd1c0 commit e98cb10

4 files changed

Lines changed: 147 additions & 36 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ let outputs = vec![TxOut {
2424

2525
let target = Target {
2626
outputs: TargetOutputs::fund_outputs(outputs.iter().map(|output| (output.weight().to_wu(), output.value.to_sat()))),
27-
fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(42.0))
27+
fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(42.0)),
28+
// An optional cap on the resulting transaction weight (e.g. for TRUC). `None` = unconstrained.
29+
max_weight: None,
2830
};
2931

3032
let candidates = vec![
@@ -130,6 +132,7 @@ let mut coin_selector = CoinSelector::new(&candidates);
130132
let target = Target {
131133
fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(15.0)),
132134
outputs: TargetOutputs::fund_outputs(outputs.iter().map(|output| (output.weight().to_wu(), output.value.to_sat()))),
135+
max_weight: None,
133136
};
134137

135138
// The feerate used to work out whether a change output would be dust (and so shouldn't be added).

src/coin_selector.rs

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,19 @@ impl<'a> CoinSelector<'a> {
111111
self.selected.contains(index)
112112
}
113113

114-
/// Is meeting this `target` possible with the current selection with this `drain` (i.e. change output).
115-
/// Note this will respect [`ban`]ned candidates.
114+
/// Whether the candidates can cover this `target`'s **value** (net of input fees) — i.e. whether
115+
/// enough value is reachable for [`is_target_met`] to hold. Respects [`ban`]ned candidates.
116116
///
117-
/// This simply selects all effective inputs at the target's feerate and checks whether we have
118-
/// enough value.
117+
/// Selecting *all* effective inputs maximizes the value available, so if that can't meet the
118+
/// target value, nothing can. Monotone, hence exact.
119+
///
120+
/// NOTE: this does **not** account for [`Target::max_weight`] — a `true` result can still be
121+
/// infeasible under the weight cap. Use [`select_until_target_met`] or branch and bound (both of
122+
/// which enforce the cap) to actually build a selection.
119123
///
120124
/// [`ban`]: Self::ban
125+
/// [`is_target_met`]: Self::is_target_met
126+
/// [`select_until_target_met`]: Self::select_until_target_met
121127
pub fn is_selection_possible(&self, target: Target) -> bool {
122128
let mut test = self.clone();
123129
test.select_all_effective(target.fee.rate);
@@ -429,18 +435,36 @@ impl<'a> CoinSelector<'a> {
429435
self.unselected_indices().next().is_none()
430436
}
431437

432-
/// Whether the constraints of `Target` have been met if we include a specific `drain` ouput.
438+
/// Whether the tx implied by the current selection and `drain` is within [`Target::max_weight`].
433439
///
434-
/// Note if [`is_target_met`] is true and the `drain` is produced from the [`drain`] method then
435-
/// this method will also always be true.
440+
/// Always `true` when `max_weight` is `None`. Note this is the *anti-monotone* half of
441+
/// feasibility (adding inputs adds weight), so it is kept separate from the monotone
442+
/// value-only [`is_target_met`](Self::is_target_met).
443+
pub fn is_within_max_weight(&self, target: Target, drain: Drain) -> bool {
444+
match target.max_weight {
445+
Some(max_weight) => self.weight(target.outputs, drain.weights) <= max_weight,
446+
None => true,
447+
}
448+
}
449+
450+
/// Whether the selection covers the target value (i.e. [`excess`](Self::excess) is
451+
/// non-negative), ignoring [`Target::max_weight`].
436452
///
437-
/// [`is_target_met`]: Self::is_target_met
438-
/// [`drain`]: Self::drain
453+
/// This is **monotone**: selecting more never un-meets it. It deliberately does *not* include
454+
/// the weight cap — see [`is_within_max_weight`](Self::is_within_max_weight).
439455
pub fn is_target_met_with_drain(&self, target: Target, drain: Drain) -> bool {
440456
self.excess(target, drain) >= 0
441457
}
442458

443-
/// Whether the constraints of `Target` have been met.
459+
/// Whether the selection covers the target **value** (net of input fees), i.e. [`excess`] is
460+
/// non-negative. **Monotone** (selecting more never un-meets it), and it deliberately does
461+
/// *not* check [`Target::max_weight`] — that is the separate, anti-monotone
462+
/// [`is_within_max_weight`]. See [`is_target_met_with_drain`] for the version that
463+
/// accounts for a specific `drain`.
464+
///
465+
/// [`excess`]: Self::excess
466+
/// [`is_within_max_weight`]: Self::is_within_max_weight
467+
/// [`is_target_met_with_drain`]: Self::is_target_met_with_drain
444468
pub fn is_target_met(&self, target: Target) -> bool {
445469
self.is_target_met_with_drain(target, Drain::NONE)
446470
}
@@ -521,14 +545,25 @@ impl<'a> CoinSelector<'a> {
521545
}
522546
}
523547

524-
/// Select candidates until `target` has been met assuming the `drain` output is attached.
548+
/// Select candidates until `target` has been met.
549+
///
550+
/// # Errors
525551
///
526-
/// Returns an error if the target was unable to be met.
527-
pub fn select_until_target_met(&mut self, target: Target) -> Result<(), InsufficientFunds> {
552+
/// - [`SelectError::InsufficientFunds`] if the candidates can't cover the target value.
553+
/// - [`SelectError::MaxWeightExceeded`] if the value is met but the resulting selection exceeds
554+
/// [`Target::max_weight`]. Note this only reflects *this* in-order greedy selection; a
555+
/// different selection might still fit the cap (use branch and bound to search for one).
556+
pub fn select_until_target_met(&mut self, target: Target) -> Result<(), SelectError> {
528557
self.select_until(|cs| cs.is_target_met(target))
529-
.ok_or_else(|| InsufficientFunds {
530-
missing: self.excess(target, Drain::NONE).unsigned_abs(),
531-
})
558+
.ok_or_else(|| {
559+
SelectError::InsufficientFunds(InsufficientFunds {
560+
missing: self.excess(target, Drain::NONE).unsigned_abs(),
561+
})
562+
})?;
563+
if !self.is_within_max_weight(target, Drain::NONE) {
564+
return Err(SelectError::MaxWeightExceeded);
565+
}
566+
Ok(())
532567
}
533568

534569
/// Select candidates until some predicate has been satisfied.
@@ -663,6 +698,38 @@ impl core::fmt::Display for InsufficientFunds {
663698
#[cfg(feature = "std")]
664699
impl std::error::Error for InsufficientFunds {}
665700

701+
/// Error returned by [`CoinSelector::select_until_target_met`].
702+
#[derive(Clone, Debug, Copy, PartialEq, Eq)]
703+
pub enum SelectError {
704+
/// The candidates can't cover the target value.
705+
InsufficientFunds(InsufficientFunds),
706+
/// The value target is met, but the resulting selection exceeds [`Target::max_weight`].
707+
MaxWeightExceeded,
708+
}
709+
710+
impl From<InsufficientFunds> for SelectError {
711+
fn from(e: InsufficientFunds) -> Self {
712+
SelectError::InsufficientFunds(e)
713+
}
714+
}
715+
716+
impl core::fmt::Display for SelectError {
717+
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
718+
match self {
719+
SelectError::InsufficientFunds(e) => write!(f, "{}", e),
720+
SelectError::MaxWeightExceeded => {
721+
write!(
722+
f,
723+
"Selection meets the target value but exceeds `max_weight`."
724+
)
725+
}
726+
}
727+
}
728+
}
729+
730+
#[cfg(feature = "std")]
731+
impl std::error::Error for SelectError {}
732+
666733
/// Error type for when a solution cannot be found by branch-and-bound.
667734
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668735
pub struct NoBnbSolution {

src/metrics/lowest_fee.rs

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,47 @@ impl LowestFee {
5353
return None;
5454
}
5555

56+
// ...and only if the change output would not push the tx over `max_weight`. If it would,
57+
// we refuse the drain and the excess goes to fee instead (a slightly conservative choice:
58+
// it can refuse change even when a no-change tx of this selection would fit).
59+
let drain = Drain {
60+
weights: self.drain_weights,
61+
value: excess_with_drain_weight.unsigned_abs(),
62+
};
63+
if !cs.is_within_max_weight(target, drain) {
64+
return None;
65+
}
66+
5667
Some(excess_with_drain_weight.unsigned_abs())
5768
}
69+
70+
/// The long-term-fee score **ignoring** the `max_weight` cap, together with the drain it
71+
/// assumes. `None` iff the value target isn't met. Used inside [`bound`](BnbMetric::bound),
72+
/// where ignoring the (feasibility) cap only loosens the lower bound and never makes it
73+
/// inadmissible; [`score`](BnbMetric::score) reuses the returned drain for its cap check so the
74+
/// drain is only decided once.
75+
fn fee_score(&self, cs: &CoinSelector<'_>, target: Target) -> Option<(Ordf32, Drain)> {
76+
if !cs.is_target_met(target) {
77+
return None;
78+
}
79+
let drain = self
80+
.drain_value(cs, target)
81+
.map_or(Drain::NONE, |value| Drain {
82+
weights: self.drain_weights,
83+
value,
84+
});
85+
let fee_for_the_tx = cs.fee(target.value(), drain.value);
86+
assert!(
87+
fee_for_the_tx >= 0,
88+
"must not be called unless selection has met target: fee={}",
89+
fee_for_the_tx
90+
);
91+
let fee_for_spending_drain = drain.weights.spend_fee(self.long_term_feerate);
92+
Some((
93+
Ordf32((fee_for_the_tx as u64 + fee_for_spending_drain) as f32),
94+
drain,
95+
))
96+
}
5897
}
5998

6099
impl BnbMetric for LowestFee {
@@ -67,30 +106,27 @@ impl BnbMetric for LowestFee {
67106
}
68107

69108
fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
70-
if !cs.is_target_met(target) {
109+
let (score, drain) = self.fee_score(cs, target)?;
110+
// A final selection must fit the weight cap. `drain_value` already refuses an over-cap
111+
// change, but a changeless selection can still be too heavy on its own. Reuse the drain
112+
// `fee_score` already decided rather than recomputing it here.
113+
if !cs.is_within_max_weight(target, drain) {
71114
return None;
72115
}
73-
74-
let long_term_fee = {
75-
let drain = self.drain(cs, target);
76-
let fee_for_the_tx = cs.fee(target.value(), drain.value);
77-
assert!(
78-
fee_for_the_tx >= 0,
79-
"must not be called unless selection has met target: fee={}",
80-
fee_for_the_tx
81-
);
82-
// `spend_fee` rounds up here. We could use floats but I felt it was just better to
83-
// accept the extra 1 sat penality to having a change output
84-
let fee_for_spending_drain = drain.weights.spend_fee(self.long_term_feerate);
85-
fee_for_the_tx as u64 + fee_for_spending_drain
86-
};
87-
88-
Some(Ordf32(long_term_fee as f32))
116+
Some(score)
89117
}
90118

91119
fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option<Ordf32> {
120+
// Weight hard-prune: input weight only grows as this branch is extended, so the lightest
121+
// solution in the subtree is this selection with no drain. If even that busts `max_weight`,
122+
// the whole subtree is infeasible -> prune. (Also keeps `fee_score(cs).unwrap()` below
123+
// sound: a value-met but over-cap node would otherwise score `None`.)
124+
if !cs.is_within_max_weight(target, Drain::NONE) {
125+
return None;
126+
}
127+
92128
if cs.is_target_met(target) {
93-
let current_score = self.score(cs, target).unwrap();
129+
let current_score = self.fee_score(cs, target).unwrap().0;
94130

95131
// `current_score` is already a valid lower bound for a selection that has change: a
96132
// descendant can never lower the fee by removing an existing (worthwhile) change
@@ -140,7 +176,7 @@ impl BnbMetric for LowestFee {
140176

141177
// If this selection is already perfect, return its score directly.
142178
if cs.excess(target, Drain::NONE) == 0 {
143-
return Some(self.score(&cs, target).unwrap());
179+
return Some(self.fee_score(&cs, target).unwrap().0);
144180
};
145181
cs.deselect(resize_index);
146182

src/target.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ pub struct Target {
77
pub fee: TargetFee,
88
/// The aggregate properties of outputs you're trying to fund
99
pub outputs: TargetOutputs,
10+
/// Maximum allowed weight of the resulting transaction (WU). `None` = unconstrained.
11+
///
12+
/// This is a feasibility constraint on the answer (the sibling of the value target: a lower
13+
/// bound on value, this an upper bound on weight). Relevant e.g. for TRUC/BIP-431 packages.
14+
pub max_weight: Option<u64>,
1015
}
1116

1217
impl Target {

0 commit comments

Comments
 (0)