feat(chain)!: taint-aware CanonicalView::balance - #2246
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2246 +/- ##
==========================================
+ Coverage 78.71% 78.80% +0.09%
==========================================
Files 31 31
Lines 5966 6039 +73
Branches 282 285 +3
==========================================
+ Hits 4696 4759 +63
- Misses 1194 1202 +8
- Partials 76 78 +2
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:
|
|
Thinking about some cases where settledness makes a transaction fall into a different I think it might be clearer to call them |
|
@Dmenec Good point. What do you think about this? pub enum Eligibility {
Settled,
Immature,
Unsettled(Trust),
}
pub enum Trust {
Trusted,
Untrusted,
}I think this increases clarity and makes it a bit easier for call sites that only care about whether it's unsettled or not. |
evanlinjin
left a comment
There was a problem hiding this comment.
Thanks for pushing this forward - this is looking great.
I haven't looked too hard into the tests yet, follow-up reviews will come.
| if txout.is_mature(tip) { | ||
| Eligibility::Settled | ||
| } else { | ||
| Eligibility::Immature | ||
| } |
There was a problem hiding this comment.
The coinbase maturity check lives inside the is_settled check - this is wrong. Unsettled is not the same as unconfirmed.
Example: A confirmed transaction can be unsettled because the caller requires 3 confirmations to be classified as settled. With the current logic, this transaction will become "pending" instead of "immature".
There was a problem hiding this comment.
So any unconfirmed coinbase tx with an owned script pubkey should be treated as Immature, not TrustedPending, even if it doesn't have a single confirmation?
There was a problem hiding this comment.
Hopefully there shouldn't be such a thing as an "unconfirmed coinbase" - the canonicalization algorithm should have considered those as non-canonical! If not, let's file a bug.
There was a problem hiding this comment.
Not even if you assume it canonical?
There was a problem hiding this comment.
Tried it with a coinbase without an anchor (which, as @evanlinjin said, shouldn't be possible on a real chain, but you can still construct it in a test), and if you assume it canonical it does end up in the set as unconfirmed.
bdk/crates/chain/src/canonical.rs
Lines 132 to 137 in 8897579
As you can see above, it falls back to false there, so such an output is treated as immature anyway.
I agree with moving the maturity check out of is_settled, so it stays Immature regardless of settledness.
There was a problem hiding this comment.
Moved the maturity check out of is_settled. Leaving this unresolved for now.
| if txout.is_mature(tip) { | ||
| Eligibility::Settled | ||
| } else { | ||
| Eligibility::Immature | ||
| } |
There was a problem hiding this comment.
So any unconfirmed coinbase tx with an owned script pubkey should be treated as Immature, not TrustedPending, even if it doesn't have a single confirmation?
8897579 to
efbb669
Compare
|
Thanks a lot @evanlinjin and @nymius for reviewing this :) |
efbb669 to
7ee5080
Compare
7ee5080 to
982244f
Compare
|
I'd like to add a I think that maybe, in a future PR, a separate Edit: still thinking about how to do it. We could do the fold directly in the wallet with a |
110CodingP
left a comment
There was a problem hiding this comment.
LGTM 🚀 , just a nit.
I especially love the test coverage, great work @Dmenec !
This is part of the protocol, not user decided, so it belongs to chain. I would do both changes in |
982244f to
3bbdf78
Compare
noahjoeris
left a comment
There was a problem hiding this comment.
ACK 3bbdf78
Great job ✨
Adds classify_outpoints, which decides per-output whether it's settled, immature, or pending (trusted, untrusted, or unknown) based on its unsettled ancestry. Trust is resolved with a memoized ancestry walk: a tainting ancestor makes it untrusted, one missing from the view makes it unknown, and ancestors shared by several outputs are only walked once. Co-authored-by: 志宇 <hello@evanlinjin.me> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
balance becomes a fold over classify_outpoints. A pending output's trust is now taken from its unsettled ancestry (untrusted if an unsettled ancestor taints or is missing from the view), not from a per-output flag. BREAKING CHANGE: balance takes does_taint and is_settled instead of trust_predicate and min_confirmations, and plain OutPoints instead of (identifier, outpoint) pairs. Co-authored-by: Dmenec <dmenec@proton.me> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- taint propagates through a pending output's unsettled ancestry - is_settled alone decides the settled boundary, even for unconfirmed outputs - taint never crosses a settled ancestor - an immature coinbase is classified apart from a settled output - two UTXOs sharing a tainting ancestor are both untrusted (shared taint cache) - classify_outpoints skips spent and out-of-view outpoints Co-authored-by: 志宇 <hello@evanlinjin.me> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Times the memoized classification over fan-in, disjoint, untrusted, diamond and deep-taint-mid-chain graphs at a range of widths and depths.
3bbdf78 to
2581fb2
Compare
evanlinjin
left a comment
There was a problem hiding this comment.
This is quality work.
I've found a final rough edge that should be hashed out before we merge.
I haven't finished reviewing the tests yet. However, we should probably rename some of them as the min_confirmations parameter no longer exists.
| // Test min_confirmations = 0: Should behave same as 1 (confirmed) | ||
| let balance_0_conf = canonical_view.balance( | ||
| [((), outpoint)], | ||
| |_, _| true, // trust all | ||
| 0, | ||
| [outpoint], | ||
| |_tx| false, // never taint (trust all) | ||
| settled(tip_height, 0), | ||
| ); | ||
| assert_eq!(balance_0_conf.confirmed, Amount::from_sat(50_000)); | ||
| assert_eq!(balance_0_conf.trusted_pending, Amount::ZERO); | ||
| assert_eq!(balance_0_conf, balance_1_conf); |
There was a problem hiding this comment.
We should remove this now since it's only testing a test helper method; settled.
| } | ||
|
|
||
| #[test] | ||
| fn test_min_confirmations_parameter() { |
There was a problem hiding this comment.
There is no min_confirmations parameter anymore. Maybe we should rename to test_is_settled_boundary?
| /// [`CanonicalView::classify_outpoints`]. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | ||
| pub enum Eligibility { | ||
| /// A confirmed output unlikely to be replaced. |
There was a problem hiding this comment.
| /// A confirmed output unlikely to be replaced. | |
| /// An output the caller considers settled, per the `is_settled` predicate given to | |
| /// [`classify_outpoints`](CanonicalView::classify_outpoints). Typically confirmed deeply | |
| /// enough to be unlikely to be replaced, but the caller decides. | |
Settled is not necessarily synonymous with "confirmed".
| outpoints: impl IntoIterator<Item = (O, OutPoint)> + 'v, | ||
| mut trust_predicate: impl FnMut(&O, &CanonicalTxOut<ChainPosition<A>>) -> bool, | ||
| min_confirmations: u32, | ||
| pub fn balance( |
There was a problem hiding this comment.
It's easy for a caller to expect that setting does_taint as |_| false would put all unconfirmed balance in Balance::trusted_pending. However this is not true as UTXOs categorized as Unsettled(Unknown) will end up in Balance::untrusted_pending. This is a potential footgun and even the tests in the repository got confused.
Essentially, does_taint can never achieve "trust all". We need to make this clear in the docs. Furthermore, I also propose introducing Balance::unknown_pending (which means trust cannot be determined due to missing ancestry) making the behavior even more explicit.
| |_, _| true, // trust all | ||
| 6, | ||
| [outpoint], | ||
| |_tx| false, // never taint (trust all) |
There was a problem hiding this comment.
"trust all" is an incorrect framing. Maybe "leave trust to ancestry" makes more sense?
| /// |_keychain, _script| true, // Trust all outputs | ||
| /// 6, // Require 6 confirmations | ||
| /// indexer.outpoints().iter().map(|(_, op)| *op), | ||
| /// |_tx| false, // Never taint (trust all) |
There was a problem hiding this comment.
Let's avoid doing |_| false in the examples and tests as it doesn't make sense.
Callers that actually want to "trust all" should do their own custom balance - our version is correct but opinionated.
| |_, _| false, // don't trust | ||
| 5, | ||
| [outpoint], | ||
| |_tx| true, // taint everything |
There was a problem hiding this comment.
Right now, even if we switch this to |_tx| false, the test still passes for the same reasons we mentioned above; hash!("parent") does not actually point to a real parent -> Trust::Unknown -> Balance::untrusted_pending.
Description
Takes over #2235 (thanks @evanlinjin for the go-ahead). It reworks
CanonicalView::balanceto derive trust from an output's unconfirmed ancestry, and addsclassify_outpoints, a per-output spend-eligibility classifier thatbalancebecomes a thin fold over.The old
balancedecided trust using a per-outputtrust_predicate, which cannot express transitive trust. As a result, owned outputs whose unconfirmed ancestry included foreign coins were counted as trusted. That is the root cause of the wallet trust-classification bugs (bitcoindevkit/bdk_wallet#16, bitcoindevkit/bdk_wallet#273).The API now takes two separate predicates, one per concern:
does_taint(&tx)- should this transaction be considered tainted? (e.g., because it spends aforeign unconfirmed output)
is_settled(&pos)- do we consider this chain position settled / final? (generalizesmin_confirmations)For each unspent output,
classify_outpointsreports its chain-level spend eligibility:Settledif considered settled according tois_settledImmaturefor a coinbase output that has not yet maturedTrustedPendingif the output is pending but trustedUntrustedPendingif the output itself, or any of its unconfirmed ancestors, is taintedbalancethen sums each output's value into the bucket corresponding to itsEligibility.Notes to the reviewers
Balance::confirmedand did not rename it tosettled. That rename is out of scope here. The folding logic is slated to move intobdk_wallet, and the currentbalancefunction inchainwill eventually be deprecated.balancealso drops theOgeneric and now takes plainOutPoints, since the taint predicate operates on transactions rather than per-outpoint associated data.does_taintis evaluated at most once per transaction.classify_outpoints. Left for a follow-up.Changelog notice
CanonicalView::balancenow takesdoes_taint: impl FnMut(&CanonicalTx) -> boolandis_settled: impl Fn(&ChainPosition) -> boolinstead of a per-output trust predicate andmin_confirmations, and plainOutPoints instead of(identifier, outpoint)pairs (this dropsthe
Ogeneric). Trust is now derived from an output's unconfirmed ancestry.CanonicalView::classify_outpointsand theEligibilityenum.Checklists
All Submissions:
New Features:
Bugfixes: