Skip to content

fix(wallet): categorize self-transfers to external addresses as trusted_pending#431

Open
Dmenec wants to merge 2 commits intobitcoindevkit:masterfrom
Dmenec:fix/trusted-pending-external-keychain
Open

fix(wallet): categorize self-transfers to external addresses as trusted_pending#431
Dmenec wants to merge 2 commits intobitcoindevkit:masterfrom
Dmenec:fix/trusted-pending-external-keychain

Conversation

@Dmenec
Copy link
Copy Markdown
Contributor

@Dmenec Dmenec commented Apr 4, 2026

Fixes #273.

Description

Unconfirmed transactions spending owned UTXOs to external addresses were being incorrectly categorized as untrusted_pending. This should not happen since all inputs are controlled by the wallet.

Notes to the reviewers

The balance function iterates over all transactions in the graph and marks a transaction as trusted if all its inputs spend from owned outpoints or from already-trusted transactions. This process repeats until no new trusted transactions are found, in order to handle chains of unconfirmed transactions transitively.

Checklists

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran just p before pushing

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

Dmenec added 2 commits April 4, 2026 14:12
…ed_pending

Removes previous balance functionality.

Now checks every outpoint owned by the wallet and searches for trusted transactions.
…pending

Add tests to verify correct balance categorization for trusted/untrusted pending transactions. Covers:
- owned inputs to external output
- external inputs to own output
- transitive trust through a chain of unconfirmed tx
@Dmenec Dmenec changed the title Fix/trusted pending external keychain fix(wallet): trusted pending external keychain Apr 4, 2026
@Dmenec Dmenec changed the title fix(wallet): trusted pending external keychain fix(wallet): categorize self-transfers to external addresses as trusted_pending Apr 4, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.16%. Comparing base (fb7681a) to head (aea97d4).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #431      +/-   ##
==========================================
+ Coverage   80.04%   80.16%   +0.12%     
==========================================
  Files          24       24              
  Lines        5336     5365      +29     
  Branches      242      247       +5     
==========================================
+ Hits         4271     4301      +30     
+ Misses        987      986       -1     
  Partials       78       78              
Flag Coverage Δ
rust 80.16% <100.00%> (+0.12%) ⬆️

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

☔ View full report in Codecov by Sentry.
📢 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.

Copy link
Copy Markdown
Collaborator

@ValuedMammal ValuedMammal left a comment

Choose a reason for hiding this comment

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

Interesting approach. I'm not sure that I follow the transitive trust logic though - if Alice sends money to Bob, she might trust her own transaction but not Bob's transaction spending the output.

.outpoints()
.iter()
.cloned()
.map(|((k, i), op)| ((k, i, op.txid), op));
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.

💯 This looks like an easier solution (including the txid with the output index) than rewriting the entire balance function on TxGraph.

self.tx_graph.index.outpoints().iter().cloned(),
|&(k, _), _| k == KeychainKind::Internal,
outpoints_with_txid,
|&(k, _, txid), _| k == KeychainKind::Internal || trusted_txids.contains(&txid),
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.

I think we should drop the KeychainKind::Internal check here entirely as there's no guarantee that foreign wallets don't pay to our change address by discovering it on chain, see #16 (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.

I agree with dropping the KeychainKind::Internal check. Another example (if I’m not mistaken) could be that, in a multisig wallet, outputs that belong to a shared descriptor can be marked trusted even if the wallet didn’t actually fund them. Dropping the check makes sure this doesn't happen.

@qatkk
Copy link
Copy Markdown

qatkk commented Apr 9, 2026

I went through the code and ran the tests, everything looks good and all the tests pass aea97d4
I just wanted to mention one small point. The current implementation might process a transaction multiple times (depending on transaction dependencies and the graph structure), since it keeps going through the graph until no new transactions are added to the trusted transaction list.
For normal wallets with small transaction graphs, this shouldn’t be an issue, but it’s something to keep in mind for larger graphs or if performance ever becomes a concern. I also came across a discussion benchmarking transaction graph reconciliation and touching on transaction graph size Issue #1687.
Even so, I think it’s probably not worth adding extra complexity (like Kahn’s algorithm) just for efficiency.

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.

Balance categorization: wallet-generated transactions to external addresses incorrectly marked as untrusted_pending

3 participants