Repository navigation
feat(key-wallet): extend CoinJoin address pools before the first sync - #1105
romchornyi wants to merge 26 commits into
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
Codecov Report❌ Patch coverage is
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
|
|
Bots are done — your move: post |
|
/self-reviewed |
|
Ready for review — |
|
i will review this soon |
ZocoLini
left a comment
There was a problem hiding this comment.
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.
|
@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 |
|
mmm, quick question, we already stopped supporting CoinJoin?? |
|
Yes - on iOS mixing is already gone, Shielded replaces it, and Android is following very soon |
|
This PR has merge conflicts with the base branch. Please rebase or merge the base branch into your branch to resolve them. |
…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>
|
@ZocoLini reworked as you suggested: the PR is now one method, 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 |
|
Waiting for bot review — coderabbitai not yet. Wait for the missing reviews, or a writer can post |
|
Bots are done — your move: address ZocoLini requested changes, then post |
|
/self-reviewed |
|
Ready for review — |
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 includinglast_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, orNoKeySourcefor a CoinJoin account the wallet cannot derive): a depth at or aboveMAX_COINJOIN_POOL_DEPTH(25,000, a runaway guard), aWalletthat 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 andaccount_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 ·
c22bce5key-wallet(key-wallet/src/wallet/managed_wallet_info/coinjoin_pools.rs,key-wallet/src/wallet/managed_wallet_info/mod.rs) — QuantumExplorer or ZocoLini or xdustinfaceWhen every merge requirement is met, the
PR Hygienecheck passes. Reviewer limits do not block merging; other required GitHub checks and protections still apply.Summary by CodeRabbit