waddrmgr: avoid in-memory side effects of ImportAccountDryRun#1207
waddrmgr: avoid in-memory side effects of ImportAccountDryRun#1207starius wants to merge 2 commits into
Conversation
Include btcsuite/btcwallet#1207 waddrmgr: avoid in-memory side effects of ImportAccountDryRun Remove this commit before merging.
There was a problem hiding this comment.
Pull request overview
Fixes ImportAccountDryRun leaving behind in-memory state after the dry-run DB transaction is rolled back, preventing post-dry-run inconsistencies (address cache “split-brain”) and unlock failures due to stale deriveOnUnlock entries.
Changes:
- Add “on-unlock side effect” toggles to the address-loading/row-to-managed pipeline to avoid mutating
deriveOnUnlockduring dry-run readback paths. - Update
nextAddressesto perform the disk round-trip verification without caching preview addresses. - Extend
InvalidateAccountCacheto also remove pendingderiveOnUnlockentries for the invalidated (dry-run) account; add a regression test covering both reported scenarios.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
wallet/import_test.go |
Adds TestImportAccountDryRunNoAddrLeak to ensure dry-run imports don’t leak address cache state and don’t break subsequent lock/unlock. |
waddrmgr/scoped_manager.go |
Introduces “OnUnlock” variants in the managed-address creation/loading chain, avoids caching during disk read-back checks, and filters stale deriveOnUnlock entries when invalidating an account cache. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // entries would cause the next Unlock to fail with account not found. | ||
| filtered := s.deriveOnUnlock[:0] | ||
| for _, info := range s.deriveOnUnlock { | ||
| if info.managedAddr.InternalAccount() != account { | ||
| filtered = append(filtered, info) | ||
| } | ||
| } |
There was a problem hiding this comment.
When filtering deriveOnUnlock with the [:0] pattern, the removed tail entries remain referenced in the underlying array. That can retain ManagedAddress objects (and any associated key material) longer than necessary. Consider explicitly nil-ing the truncated tail before re-slicing/assigning, or allocating a new slice, so removed entries can be GC’d promptly.
| // entries would cause the next Unlock to fail with account not found. | |
| filtered := s.deriveOnUnlock[:0] | |
| for _, info := range s.deriveOnUnlock { | |
| if info.managedAddr.InternalAccount() != account { | |
| filtered = append(filtered, info) | |
| } | |
| } | |
| // entries would cause the next Unlock to fail with account not found. | |
| origLen := len(s.deriveOnUnlock) | |
| filtered := s.deriveOnUnlock[:0] | |
| for _, info := range s.deriveOnUnlock { | |
| if info.managedAddr.InternalAccount() != account { | |
| filtered = append(filtered, info) | |
| } | |
| } | |
| var zeroInfo unlockDeriveInfo | |
| for i := len(filtered); i < origLen; i++ { | |
| s.deriveOnUnlock[i] = zeroInfo | |
| } |
There was a problem hiding this comment.
Fixed. I allocated new slice. This code is not performance critical, so we don't need this slice-reuse optimization here. Zeroing code looks too heavy to use it, so I decided just to make a new slice.
| tc := testCases[len(testCases)-1] // bip84, accountIndex=1 | ||
|
|
There was a problem hiding this comment.
This test picks the BIP84 case via testCases[len(testCases)-1], which makes it order-dependent and easy to break when new cases are appended. Consider selecting the desired test case by matching on tc.name/expectedScope/accountIndex instead of relying on slice ordering.
| tc := testCases[len(testCases)-1] // bip84, accountIndex=1 | |
| var tc *testCase | |
| for i := range testCases { | |
| if testCases[i].accountIndex == 1 && | |
| testCases[i].expectedScope == waddrmgr.KeyScopeBIP0084Plus { | |
| tc = &testCases[i] | |
| break | |
| } | |
| } | |
| require.NotNil(t, tc, "expected bip84 test case with accountIndex=1") |
nextAddresses used loadAndCacheAddress as a post-write check, rebuilding ManagedAddress state before commit. On dry-run rollback, ImportAccountDryRun could then leak preview addresses into s.addrs and leave stale deriveOnUnlock entries behind. Restore the round-trip read-back check with a non-caching loadAddress helper. This preserves the stronger validation while avoiding cache updates for dry-run transactions. Also have InvalidateAccountCache clear deriveOnUnlock entries for the rolled-back account, so a later unlock does not fail with "account not found".
Add coverage for the two dry-run side effects fixed here: - watch-only dry-run must not leak preview addresses into lookups, and AccountOfAddress must fail with ErrAddressNotFound consistently - normal-wallet dry-run must not leave stale deriveOnUnlock state behind or break the next Lock/Unlock cycle after the dry run The test also checks that a real import and address derivation still behave normally afterward.
e15120a to
a7bdbe9
Compare
Abdulkbk
left a comment
There was a problem hiding this comment.
LGTM
just one non-blocking nit.
| w.Lock() | ||
|
|
||
| err = w.Unlock( | ||
| []byte("world"), time.After(10*time.Minute), |
There was a problem hiding this comment.
nit: 10 mins seem excessive for this test
There was a problem hiding this comment.
10 mins it is the time after which the wallet will lock again. If we choose too small value, it might become flaky in CI, so I propose to keep 10 mins.
Change Description
Fix two in-memory side effects in
ImportAccountDryRunthat survive the dry-run DB rollback.Problem 1 -- address cache leak.
nextAddressescallsloadAndCacheAddressfor a post-write round-trip check, which populatesScopedKeyManager.addrsbefore commit. On dry-run rollback these entries survive, soManager.Address(addr)succeeds from cache whileManager.AddrAccount(addr)fails from the rolled-back DB -- a split-brain inconsistency.Problem 2 -- stale
deriveOnUnlockentries. During dry-run,loadAccountInfocallskeyToManagedfor the account's last derived addresses, which appends toderiveOnUnlockwhen the derived key is public-only. After rollback these entries reference a non-existent account, causing the nextManager.Unlockto fail withaccount N not found.Fix (commit 1): Thread an
onUnlockparameter through the address-loading call chain (keyToManaged,chainAddressRowToManaged,rowInterfaceToManaged,loadAndCacheAddress) so callers can skip thederiveOnUnlockappend. Add aloadAddresshelper that uses this to perform the round-trip read-back check without any cache orderiveOnUnlockside effects, preserving the full address mismatch validation. Also extendInvalidateAccountCacheto filterderiveOnUnlockentries for the invalidated account.Fix (commit 2): Add
TestImportAccountDryRunNoAddrLeakcovering both scenarios: watch-only wallet (cache leak / split-brain) and normal wallet (stalederiveOnUnlockbreaking Lock/Unlock).Steps to Test
The new test has two subtests:
watch-only: dry-run preview addresses must not be visible viaHaveAddress;AccountOfAddressmust returnErrAddressNotFoundconsistently (no split-brain). A subsequent real import and address derivation must work normally.normal wallet unlock after dry-run:LockthenUnlockmust succeed after a dry-run import (previously failed withaccount 1 not found).Pull Request Checklist
Testing
Code Style and Documentation