Skip to content

Add ancestor-aware CPFP package pricing - #43

Closed
evanlinjin wants to merge 1 commit into
bitcoindevkit:masterfrom
evanlinjin:feature/ancestor-aware-cs
Closed

Add ancestor-aware CPFP package pricing#43
evanlinjin wants to merge 1 commit into
bitcoindevkit:masterfrom
evanlinjin:feature/ancestor-aware-cs

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Mar 30, 2026

Copy link
Copy Markdown
Member

Summary

When spending unconfirmed UTXOs, miners evaluate the transaction as a package with its unconfirmed ancestors. If ancestors paid below the target feerate, the child must overpay (CPFP). This PR makes coin selection price that bump correctly, with automatic deduplication when multiple candidates share ancestors.

Closes #24. Competes with #38 and #42.

Scope: pricing, not selection

Worth stating up front, because it shapes everything below. This PR lets you say "I am spending these unconfirmed UTXOs" and have the resulting package priced correctly in every excess, effective-value and fee calculation. It does not let the selection algorithms decide for themselves whether spending an unconfirmed UTXO is worthwhile — candidates with unconfirmed ancestors are banned from automatic selection.

That matches the actual CPFP flow, where the input you're bumping is mandatory rather than optional: select it, then let branch and bound or select_until_target_met optimize the confirmed candidates around it, with the bump priced into every calculation along the way.

The reason it isn't optional is in the next section, and it's the main thing I'd like review on.

Design

Each ancestor is tracked individually via UnconfirmedAncestor { weight, fee_paid, dependent_candidates }, handed to the selector once through CoinSelector::with_ancestors().

Note the direction of the edge: the ancestor names the candidates that depend on it, rather than each candidate naming its ancestors. Candidate is therefore untouched — it stays a plain Copy struct with no lifetime parameter, and existing code needs no changes. Deduplication also falls out for free: each ancestor is visited once per selection, with no intermediate collect/sort/dedup pass.

The bump is computed at the package level: include every ancestor with at least one selected dependent, sum weights and fees, then max(0, implied_fee(total_weight, feerate) - total_fees). High-feerate ancestors subsidize low-feerate ones within the package, matching Bitcoin Core's package relay approach from PR #27021.

Why ancestor candidates are banned from automatic selection

The bump is a property of the package, not of any one candidate — shared ancestors count once, and an overpaying ancestor subsidizes an underpaying one. So the cost of adding a candidate depends on what else is selected.

Every selection algorithm in this crate ranks and accumulates using per-candidate figures (Candidate::effective_value, Candidate::value_pwu) which structurally cannot see ancestors. If such a candidate were selectable, adding one could lower the excess, and every algorithm breaks in a way that produces no error:

  • select_until_target_met and select_srd can report insufficient funds when a funding selection exists
  • is_fundable returns false negatives, so BnB misreports NoBnbSolution::InsufficientFunds
  • LowestFee::bound stops being a lower bound and silently prunes the optimal branch
  • Changeless::change_unavoidable prunes branches that do contain changeless solutions

Banning keeps the reachable ancestor set — and hence the bump — identical across every selection an algorithm can produce, which is exactly what those algorithms need. Bans don't block manual selection, so the CPFP flow is unaffected.

Since that invariant is enforced in one place and depended on in several, the dependent sites carry a note pointing at with_ancestors, and a debug_assert in selected_ancestor_bump_fee fires if a dependent candidate ever stops being banned.

Where the bump lives

Rather than modifying weight() or fee() (which would need _without_package escape hatches for RBF), the bump is folded into the private implied_package_fee_* helpers. Every caller of those needed it, so putting it inside means a new call site can't forget it. TargetFee::absolute stays bump-free, since it's a minimum fee floor rather than an additive charge.

This makes implied_fee() the exact counterpart of excess():

excess == selected_value - target.value() - drain.value - implied_fee

Comparison with the alternatives

#42 (ancestor_bump_fee field on Candidate) pre-computes the bump per candidate. Two consequences:

  1. No deduplication — a shared ancestor is double-counted, so selection overpays.
  2. Fixed feerate — the bump is baked in at construction, so it's wrong at any other feerate.

But the honest counterpoint, which I got to while working through the above: #42's model is local, and that locality is precisely what makes it safe to select on. Per-candidate effective value tells the whole story there, so their algorithms can freely choose unconfirmed UTXOs. This PR's model is more accurate and, for exactly that reason, only usable for pricing. The overpayment #42 accepts is what buys them the ability to choose.

#38 (Package struct) uses a single aggregate Package { parent_fee, parent_weight }. That works for one parent but can't deduplicate shared ancestors as the selection changes. The per-ancestor model handles that naturally:

  Ancestor A (400 wu, 10 sat fee)
    └── dependent_candidates: [0, 1]

Selecting either candidate counts A once; selecting both still counts it once.

API changes

Purely additive — no existing type or signature changes.

  • UnconfirmedAncestor — new: { weight: u64, fee_paid: u64, dependent_candidates: Vec<usize> }
  • CoinSelector::with_ancestors(&'a [UnconfirmedAncestor]) -> Self — records the ancestors and bans the dependent candidates. A selector without it behaves exactly as before. Panics if a dependent_candidates index is out of bounds.
  • CoinSelector::selected_ancestor_bump_fee(feerate) -> u64 — package-level bump with deduplication
  • CoinSelector::candidates_with_ancestors() -> impl Iterator<Item = usize> — which candidates were banned, deduplicated, and distinct from banned() (which also carries your own bans)
  • rate_excess, rate_excess_wu, replacement_excess, replacement_excess_wu, effective_value, implied_fee — now price the package

Testing

tests/ancestor_aware.rs covers: no-ancestor backward compatibility, a single ancestor reducing excess, shared ancestors deduplicated, a high-feerate ancestor subsidizing a low-feerate one, an already-sufficient package contributing zero bump, bump scaling with feerate, effective_value accounting, implied_fee carrying the bump, the excess/implied_fee identity across ancestor sets × absolute fees × replacements × feerates × drains × selections, the ban taking effect through unselected_indices and select_until_target_met, manual selection still working, and candidates_with_ancestors deduplication.

The suite passes in both debug and release — debug matters here, as that's the only build where the debug_assert runs.

Known follow-ups

  • rate_excess_wu / replacement_excess_wu exist to avoid vbyte rounding, but the bump they subtract still uses FeeRate::implied_fee (which rounds to whole vbytes) rather than implied_fee_wu. Small in sats, but it makes those bounds slightly over-tight. One line each to fix now that the bump is folded into the helpers.
  • selected_ancestor_bump_fee is O(ancestors × dependents) per call. Storing each ancestor's dependents as a Bitset would make it a bitwise AND. Pure optimization, no semantic change.
  • implied_feerate() reports the child's standalone feerate rather than the package feerate.

Co-authored with @noahjoeris, who contributed the inverted ancestor → candidate edge this design is built on.

@noahjoeris noahjoeris left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What do you think of inverting the ancestor ↔ candidate edge?
Instead of Candidate.ancestors: Vec<usize>, store the link on the ancestor:

pub struct UnconfirmedAncestor {
    pub weight: u64,
    pub fee_paid: u64,
    pub dependent_candidates: Vec<usize>, // candidate indices depending on this ancestor
}

We could keep Candidate: Copy. I think it wouldn't change much for the caller.
Also avoids an unused ancestors field on Candidates if caller doesn't care about CPFP.

@evanlinjin

Copy link
Copy Markdown
Member Author

What do you think of inverting the ancestor ↔ candidate edge?

@noahjoeris Interesting idea. My main worry with this is in the ergonomics of constructing it. Worth an exploration.

@noahjoeris

Copy link
Copy Markdown

Ok I'll explore

@noahjoeris

Copy link
Copy Markdown

Here is the version with ancestors containing the dependent_candidates
noahjoeris#1

@evanlinjin
evanlinjin force-pushed the feature/ancestor-aware-cs branch from ce09897 to e381135 Compare July 27, 2026 13:53
When spending unconfirmed UTXOs, miners evaluate the transaction as a
package with its ancestors. This adds support for computing the
package-level bump fee needed to bring ancestors up to the target
feerate, with automatic deduplication of shared ancestors across
candidates.

- Add UnconfirmedAncestor struct (weight, fee_paid, dependent_candidates)
- Add with_ancestors() builder on CoinSelector
- Add selected_ancestor_bump_fee() with package-level computation
- Add candidates_with_ancestors() to report the affected candidates
- Fold the bump into the implied_package_fee_* helpers, so every excess
  calculation and implied_fee() price the package rather than the tx alone

The ancestor -> candidate edge is stored on the ancestor rather than on
the candidate, so `Candidate` stays `Copy` and each ancestor is counted
at most once per selection without an intermediate dedup pass.

Candidates with unconfirmed ancestors are banned from automatic selection.
The bump is a property of the package, not of any one candidate: shared
ancestors count once and an overpaying ancestor subsidizes an underpaying
one, so the cost of adding a candidate depends on what else is selected.
Every algorithm here ranks with per-candidate figures that cannot see
ancestors, so a selectable ancestor candidate could *lower* the excess and
break them silently -- select_until_target_met and select_srd reporting
insufficient funds when a funding selection exists, is_fundable giving false
negatives, LowestFee::bound no longer bounding, Changeless pruning branches
that do hold changeless solutions. Banning keeps the reachable ancestor set,
and hence the bump, identical across every selection an algorithm can reach.

Bans do not block manual selection, so the CPFP flow is unaffected: select
the unconfirmed UTXO you are bumping, then let the algorithms optimize the
confirmed candidates around it. The invariant is noted at the sites that
depend on it, and a debug_assert in selected_ancestor_bump_fee fires if a
dependent candidate ever stops being banned.

Co-Authored-By: Noah Joeris <noahjoeris@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@evanlinjin
evanlinjin force-pushed the feature/ancestor-aware-cs branch from e381135 to 6219c8f Compare July 28, 2026 07:13
@evanlinjin evanlinjin changed the title Add ancestor-aware coin selection for CPFP bump fee calculation Add ancestor-aware CPFP package pricing Jul 28, 2026
@evanlinjin

Copy link
Copy Markdown
Member Author

After thinking further on this problem, turns out we overlooked #24 (comment). The only correct solution is something MiniMiner-equivalent. Ancestors are evaluated on a per-package basis, and the flat UnconfirmedAncestor list would not work as that excludes topology.

@evanlinjin evanlinjin closed this Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CPFP: Allow telling coin selector that a candidate has unconfirmed parents

2 participants