Skip to content

feat(key-wallet): extend CoinJoin address pools before the first sync - #1105

Open
romchornyi wants to merge 26 commits into
devfrom
fix/32008-coinjoin-targeted-discovery
Open

romchornyi wants to merge 26 commits into
devfrom
fix/32008-coinjoin-targeted-discovery

Conversation

@romchornyi

@romchornyi romchornyi commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

A wallet migrated from DashSync can hold mixed coins on CoinJoin addresses far past the gap limit. DashSync marked an address used the moment it handed it to a mixing session, so failed sessions left runs of empty addresses wider than the gap, and discovery stops at the first one.

Per review, this PR now does only what the remove + re-add flow needs: ManagedWalletInfo::extend_coinjoin_pools(&Wallet, last_index) derives both pools of every CoinJoin account up to and including last_index, on fresh wallet state, before the first sync from birth. The normal sync then finds the coins. A fresh wallet has no finalized txids, so the old ChainLocked mixes are credited as first sightings; the tests run in a ChainLocked block.

Before anything changes it rejects (InvalidParameter, or NoKeySource for a CoinJoin account the wallet cannot derive): a depth at or above MAX_COINJOIN_POOL_DEPTH (25,000, a runaway guard), a Wallet that does not own the info (id or network), state that already processed or certified a block past birth or recorded a transaction, and a wallet without a CoinJoin account. It bumps each grown account's monitor revision and account_generation, so a filter batch scanned before the call cannot commit.

The pools stay that deep: every extra script is matched against every compact filter (about 3% of blocks match falsely at 12,000 per chain), and past about 18,000 scripts dash-spv's size-capped mempool bloom filter runs above its target false-positive rate. The caller chooses the depth; the only DashSync log we have reported 10,530 CoinJoin addresses derived.

The reset + replay recovery (begin_coinjoin_recovery, its events, FFI and probe) is removed.

Tested end to end on testnet against a local platform + iOS integration (remove stores, re-add from the keychain seed with depth 12,000, sync from birth): coins at CoinJoin 0/1200, 0/2500, 1/250 and 1/260 were found, and a wallet without CoinJoin coins kept its balance. Unit tests: the boundary (a coin at the last index is found, one past it is not, on both chains and networks) and every rejection; the key-wallet suite passes with and without keep-finalized-transactions.

🤖 Generated with Claude Code

PR Hygiene · c22bce5

  • Bots — coderabbitai ✓
  • Self-review — posted; again after any push
  • Build green
  • Approvals
    • key-wallet (key-wallet/src/wallet/managed_wallet_info/coinjoin_pools.rs, key-wallet/src/wallet/managed_wallet_info/mod.rs) — QuantumExplorer or ZocoLini or xdustinface
    • ZocoLini requested changes — waiting for them to re-review or dismiss

When every merge requirement is met, the PR Hygiene check passes. Reviewer limits do not block merging; other required GitHub checks and protections still apply.

Summary by CodeRabbit

  • New Features
    • Added support for extending CoinJoin address pools before a wallet’s first sync, helping wallets detect coins at deeper address indexes.
    • Pool extension is limited to a maximum depth and is available only for eligible wallets that have not synced past their birth point or recorded transactions.

jeanpierreroma and others added 11 commits October 2, 2026 13:49
A wallet migrated from DashSync can hold mixed coins past a run of empty
CoinJoin addresses wider than the gap limit: DashSync marked an address used
when it handed it to a mixing session, so every failed session left an empty
index behind. Discovery stops at the first such run, and every later coin
stays invisible through any number of rescans.

When a transaction spends our CoinJoin coins of a denomination but pays fewer
outputs of that denomination back to our pools, derive each CoinJoin chain
past its generated end (up to COINJOIN_RECOVERY_PROBE_WINDOW) and compare
against the transaction's unclaimed outputs of that value. A match extends the
pool to it before the transaction is checked, so the output is credited and
the new addresses reach SPV as newly derived scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er chain

DashSync derived up to ~10,500 CoinJoin addresses on heavily mixed wallets,
so a 2,000 window can stop short. Cache the probe derivation per chain so
the many one-sided mixes of one wallet derive the window once. The whole
window is still compared on every trigger: a mix can return more of our coins
than the inputs the wallet knows, and stopping early would lose them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Address review: the probe cache moves from a process-global static into
ManagedWalletInfo (never persisted, a clone starts empty), so no global lock
is held while deriving and nothing outlives the wallet. Each chain caches only
the probed window instead of every index from 0. Recovery runs only when the
router includes CoinJoin accounts, tests derive their indices from the CoinJoin
gap constant, and the module doc states that older blocks need a rescan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tensions

Address review: the cache keeps only the window above the pool end and moves up
with it, an index that fails to derive is not retried on every later mix, and a
pool extension that fails partway keeps only the coins below the failure instead
of logging them as recovered. Adds a mainnet test and a test of a recovered coin
mixed again past the new pool end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the gap to the check

Address review: the probe derives each chain's branch key once, so an index
costs one child derivation instead of two plus an AddressInfo. Recovery only
extends the pool through the found index; the regular check marks it used and
maintains the gap as it does for any output. Drops a range filter that could
never remove anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…may own

Address review: the trigger counted how many coins of a denomination came back
against the known inputs, so a mix that also spent a coin of ours the wallet had
not found yet could return that one inside the pool and hide the missing one,
and denominations DashSync created from the main account were never looked for.
Probe whenever a transaction spends any coin of ours and pays a CoinJoin
denomination to a script no account of ours holds. The new app no longer mixes,
so this fires while replaying history, once per process for the derivation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ir funding

Address review: a mix delivered before the coin it spends (rescan order) now
triggers through its output to our pool, and collateral amounts (0.0001-0.0004
DASH) are probed alongside denominations. The probe cache is tied to the branch
key it was derived from, and a transaction with no CoinJoin-shaped output
returns before any account is scanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tions

Address review: a one-sided mix that a chainlock already made final went
through confirm_transaction, which no longer touches UTXOs, so a coin found in
it on a rescan was marked used but never credited. Credit those outputs, and
let a rescan's redelivery trigger the probe through the spend it recorded.
Once probing, every output no account holds is compared, so DashSync's odd
change past the wall is found with the coins. The collateral range derives from
the smallest denomination, and the probe cache is keyed by the account key with
a plain branch key and no sticky failure state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecovery trigger

Address review: crediting every output of an already-final transaction could
re-insert change a later final transaction had spent, never debited recovered
coins spent by final transactions, and emitted no record — a phantom-balance
risk. Final transactions are left to confirm_transaction as before; the
observed-spent trigger, which matched every spend in a block, goes with it.
The trigger now partitions the outputs once, and the probe cache uses the
entry API instead of a panicking lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Address review: probed addresses are all P2PKH, so the cache keeps 20-byte key
hashes instead of heap-allocated scripts; the unclaimed outputs are collected
only once the transaction is known to involve us; the reuse-or-reset decision
on the cache is a plain lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 465d0a7 and c22bce5.

📒 Files selected for processing: 2
  • key-wallet/src/wallet/managed_wallet_info/coinjoin_pools.rs
  • key-wallet/src/wallet/managed_wallet_info/mod.rs

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

The coinjoin_pools module is now public. It defines a maximum pool depth and adds a method to extend CoinJoin address pools for eligible wallets. The method validates wallet state, generates missing addresses, updates account state, and returns the number of generated addresses.

Changes: CoinJoin pool extension

Layer / File(s) Summary
Pool depth contract
key-wallet/src/wallet/managed_wallet_info/coinjoin_pools.rs, key-wallet/src/wallet/managed_wallet_info/mod.rs
Defines the exclusive maximum pool depth of 25,000 and publicly exposes the coinjoin_pools module.
Pool extension and validation
key-wallet/src/wallet/managed_wallet_info/coinjoin_pools.rs
Adds extend_coinjoin_pools, which validates wallet identity, network, sync state, CoinJoin accounts, key sources, and requested depth before extending both pools through the inclusive index. Tests cover extension, account state updates, and rejection cases.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Feature

Merge Risk: ⚪ Minimal · up to c22bc

No actionable merge-blocking issue was established. Callers using wallet-only import must opt in to pool extension again before syncing.

Security architecture risk: 🔵 Low · up to f125f

Coin recovery remains bounded and tied to the wallet’s own keys. No ownership bypass was established, but end-to-end recovery after interruptions and chain reorganizations remains incompletely verified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The new computational exposure scales with CoinJoin accounts in each checked wallet: an initial cache fill can derive 10,000 children on each external and internal chain. Successful matches also grow monitored address state through the matched index. Account-key validation and per-wallet publication constrain attribution; repeated unmatched transactions reuse an unchanged cached interval.

Security Findings and Attack Paths

  • inferred — A third party knowing a wallet address can construct a payment to that address with an additional unclaimed CoinJoin-valued output, satisfying eligibility without spending a wallet coin. This introduces attacker-triggerable bounded derivation work compared with the prior checker. Cache reuse is strong counterevidence against repeated full-window amplification, and an exploitable availability failure was not established.

Trust Boundaries and Controls

  • observed — Transaction data crosses into address-state mutation only after wallet-involvement screening and account-derived hash matching. Probing is restricted to public-key-backed CoinJoin accounts and normal external/internal branches; it does not accept an arbitrary transaction script as proof of ownership.

Resilience and Maintainability Implications

  • observed — The downstream filter manager reconciles monitored scripts against untested scripts before committing an active scanned batch and waits when reconciliation finds additional blocks. This provides a recovery mechanism independent of a single newly-derived-script notification, but does not establish durable restart behavior or replay of previously committed history.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 67.83% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 143 functions across 24 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: extending CoinJoin address pools before the wallet's first sync.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the waiting-bots Waiting for the review bots to report on this head label Oct 3, 2026
@codecov

codecov Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.41860% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.00%. Comparing base (6baf1d3) to head (c22bce5).

Files with missing lines Patch % Lines
...t/src/wallet/managed_wallet_info/coinjoin_pools.rs 99.41% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #1105      +/-   ##
==========================================
+ Coverage   78.96%   79.00%   +0.04%     
==========================================
  Files         304      305       +1     
  Lines       80988    81160     +172     
==========================================
+ Hits        63951    64121     +170     
- Misses      17037    17039       +2     
Flag Coverage Δ
core 77.74% <ø> (ø)
ffi 51.74% <ø> (-0.01%) ⬇️
rpc 52.05% <ø> (ø)
spv 92.37% <ø> (-0.06%) ⬇️
wallet 82.72% <99.41%> (+0.17%) ⬆️
Files with missing lines Coverage Δ
key-wallet/src/wallet/managed_wallet_info/mod.rs 76.00% <ø> (ø)
...t/src/wallet/managed_wallet_info/coinjoin_pools.rs 99.41% <99.41%> (ø)

... and 10 files with indirect coverage changes

coderabbitai[bot]
coderabbitai Bot previously approved these changes Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Bots are done — your move: post /self-reviewed.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed and removed waiting-bots Waiting for the review bots to report on this head labels Oct 3, 2026
@romchornyi

Copy link
Copy Markdown
Contributor Author

/self-reviewed

@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Ready for review — key-wallet: QuantumExplorer or ZocoLini or xdustinface.
Full checklist in the description.

@github-actions github-actions Bot added ready-for-human Bots have reported, the author has self-reviewed, and the build is green: this needs a human. and removed waiting-self-review Waiting for the author to post /self-reviewed labels Oct 3, 2026
@ZocoLini

ZocoLini commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

i will review this soon

@ZocoLini ZocoLini left a comment

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.

Rather than probing on every transaction for every wallet, could we just pre-generate the CoinJoin pools up to the highest index DashSync derived when the wallet is imported? A method that extends the CoinJoin pools to a given index, called by the app only for migrated wallets before the rescan. It's a couple of lines, and wallets that never touched DashSync pay nothing.

Filter matching against the pool is already covered, so this doesn't need much testing on our side. Deciding when to call it is up to the app. At most, one unit test: an address used at the last generated index is found, and one just past it isn't. Written against the internal constant, so it doesn't have to be public.

@romchornyi

Copy link
Copy Markdown
Contributor Author

@ZocoLini thanks for looking at this. Pre-generating to DashSync's highest index would be the simplest fix if the app had that index, but it does not: DashSync is out of the binary and the migration carries over only the mnemonic. The wallet this fix was written for was reinstalled and restored from seed, so no DashSync state exists for it at all. Without the real index the app would have to pass a fixed bound for every wallet — DashSync derived ~10,500 CoinJoin addresses on the affected wallet, so that is ~21k extra watched scripts per wallet on the first scan, and still a wall.

You are right about the cost for wallets that never mixed, though: the collateral range makes the trigger fire on ordinary small payments. I can gate the probe behind a per-wallet opt-in that the app enables only for its one-time recovery scan, so every other wallet pays nothing. Would that work for you?

🤖 Generated with Claude Code

@ZocoLini

ZocoLini commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

mmm, quick question, we already stopped supporting CoinJoin??

@romchornyi

Copy link
Copy Markdown
Contributor Author

Yes - on iOS mixing is already gone, Shielded replaces it, and Android is following very soon

@github-actions github-actions Bot added bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. merge-conflict The PR conflicts with the target branch. labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR has merge conflicts with the base branch. Please rebase or merge the base branch into your branch to resolve them.

jeanpierreroma and others added 11 commits October 22, 2026 14:58
…with extendable pools

Per review: drop the reset + replay recovery (wallet-manager entry point,
events, FFI, scan engine) and keep only what the remove + re-add flow
needs. ManagedWalletInfo::extend_coinjoin_pools derives both pools of every
CoinJoin account to a given depth on fresh wallet state, before the first
sync from birth, so mixed coins far past the gap limit are found by the
regular sync. trim_coinjoin_pools drops the unused tail afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ug runs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reject a depth at or above MAX_COINJOIN_POOL_DEPTH instead of overflowing
or deriving for ever, and bump the monitor revision for every account whose
pools grew even when derivation fails partway. Drop trim_coinjoin_pools: it
has no caller and could prune addresses before the sync from birth is done.
Tests run on a fixed seed, on mainnet and testnet, and cover the monitor
revision and the depth bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check the wallet owns the info and every CoinJoin account has a key source
before any pool changes, instead of silently skipping an account. Lower the
depth cap to 25,000 so the mempool bloom filter stays usable, derive in
place without collecting the addresses, and pin the inclusive depth edge,
the untouched non-CoinJoin accounts and the ownership check in tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reject a wallet info that has processed a block past its birth or holds
transactions, and one without a CoinJoin account, instead of returning
Ok(0). Document the real filter cost of deep pools. Cut the tests down to
the boundary the reviewer asked for (found at the depth, missed one past
it, in a ChainLocked block on both chains and networks) plus one test for
every rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also refuse a wallet info with a certified sync height or an applied
ChainLock, report a missing key source as Error::NoKeySource, derive every
missing index up to the depth instead of only past the highest one, and
bump account_generation so a filter batch scanned before the call cannot
commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…twork

dash-spv applies ChainLocks to every wallet whether it scanned or not, so
only the heights and recorded transactions prove a scan. Check the network
along with the wallet id, correct the bloom filter threshold in the docs and
move the per-pool fill into a helper with a single error path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check

A ChainLocked transaction's record is pruned in the default build and only
its txid is kept, so test tx_count instead of the transaction map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@romchornyi romchornyi changed the title feat(key-wallet)!: opt-in CoinJoin recovery replay for coins stranded past the gap limit feat(key-wallet): extend CoinJoin address pools before the first sync Oct 22, 2026
@github-actions github-actions Bot removed the merge-conflict The PR conflicts with the target branch. label Oct 22, 2026
@romchornyi

Copy link
Copy Markdown
Contributor Author

@ZocoLini reworked as you suggested: the PR is now one method, ManagedWalletInfo::extend_coinjoin_pools(&Wallet, last_index), plus tests (+266 lines, two files). The reset + replay machinery is gone.

Tried it end to end on testnet first: the app removes its local stores, re-adds the wallet from the seed with the CoinJoin pools extended to 12,000, and syncs from birth. Coins at CoinJoin 0/1200, 0/2500, 1/250 and 1/260 showed up, and a wallet without CoinJoin coins kept its balance. You were right about the ChainLock issue: on fresh state the old mixes are credited as first sightings.

It refuses state that is not fresh, so the precondition is enforced, not only documented. Two costs worth knowing: the pools stay deep (about 3% of blocks match the compact filters falsely at 12,000 per chain), and nothing trims them afterwards. If that is fine for a one-off recovery, nothing else is needed here; the platform and app changes will follow in their own PRs.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added waiting-bots Waiting for the review bots to report on this head and removed waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. labels Oct 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Bots are done — your move: address ZocoLini requested changes, then post /self-reviewed.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed and removed waiting-bots Waiting for the review bots to report on this head labels Oct 22, 2026
@romchornyi

Copy link
Copy Markdown
Contributor Author

/self-reviewed

@github-actions github-actions Bot removed the waiting-self-review Waiting for the author to post /self-reviewed label Oct 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Ready for review — key-wallet: QuantumExplorer or ZocoLini or xdustinface · re-review or resolve: ZocoLini.
Full checklist in the description.

@github-actions github-actions Bot added the ready-for-human Bots have reported, the author has self-reviewed, and the build is green: this needs a human. label Oct 22, 2026
@github-actions
github-actions Bot requested a review from ZocoLini October 22, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Bots have reported, the author has self-reviewed, and the build is green: this needs a human.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants