Skip to content

waddrmgr: avoid in-memory side effects of ImportAccountDryRun#1207

Open
starius wants to merge 2 commits into
btcsuite:masterfrom
starius:ImportAccountDryRun-side-effects
Open

waddrmgr: avoid in-memory side effects of ImportAccountDryRun#1207
starius wants to merge 2 commits into
btcsuite:masterfrom
starius:ImportAccountDryRun-side-effects

Conversation

@starius

@starius starius commented Apr 8, 2026

Copy link
Copy Markdown

Change Description

Fix two in-memory side effects in ImportAccountDryRun that survive the dry-run DB rollback.

Problem 1 -- address cache leak. nextAddresses calls loadAndCacheAddress for a post-write round-trip check, which populates ScopedKeyManager.addrs before commit. On dry-run rollback these entries survive, so Manager.Address(addr) succeeds from cache while Manager.AddrAccount(addr) fails from the rolled-back DB -- a split-brain inconsistency.

Problem 2 -- stale deriveOnUnlock entries. During dry-run, loadAccountInfo calls keyToManaged for the account's last derived addresses, which appends to deriveOnUnlock when the derived key is public-only. After rollback these entries reference a non-existent account, causing the next Manager.Unlock to fail with account N not found.

Fix (commit 1): Thread an onUnlock parameter through the address-loading call chain (keyToManaged, chainAddressRowToManaged, rowInterfaceToManaged, loadAndCacheAddress) so callers can skip the deriveOnUnlock append. Add a loadAddress helper that uses this to perform the round-trip read-back check without any cache or deriveOnUnlock side effects, preserving the full address mismatch validation. Also extend InvalidateAccountCache to filter deriveOnUnlock entries for the invalidated account.

Fix (commit 2): Add TestImportAccountDryRunNoAddrLeak covering both scenarios: watch-only wallet (cache leak / split-brain) and normal wallet (stale deriveOnUnlock breaking Lock/Unlock).

Steps to Test

go test ./waddrmgr/ -count=1
go test ./wallet/ -run TestImportAccountDryRunNoAddrLeak -v -count=1 -timeout 600s
go test ./wallet/ -run TestImportAccount -count=1 -timeout 600s

The new test has two subtests:

  • watch-only: dry-run preview addresses must not be visible via HaveAddress; AccountOfAddress must return ErrAddressNotFound consistently (no split-brain). A subsequent real import and address derivation must work normally.
  • normal wallet unlock after dry-run: Lock then Unlock must succeed after a dry-run import (previously failed with account 1 not found).

Pull Request Checklist

Testing

  • Your PR passes all CI checks.
  • Tests covering the positive and negative (error paths) are included.
  • Bug fixes contain tests triggering the bug to prevent regressions.

Code Style and Documentation

starius added a commit to starius/lnd that referenced this pull request Apr 8, 2026
Include btcsuite/btcwallet#1207
waddrmgr: avoid in-memory side effects of ImportAccountDryRun

Remove this commit before merging.
@GustavoStingelin GustavoStingelin requested a review from Copilot April 8, 2026 17:09

Copilot AI left a 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.

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 deriveOnUnlock during dry-run readback paths.
  • Update nextAddresses to perform the disk round-trip verification without caching preview addresses.
  • Extend InvalidateAccountCache to also remove pending deriveOnUnlock entries 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.

Comment on lines +2637 to +2643
// 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)
}
}

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
// 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
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread wallet/import_test.go Outdated
Comment on lines +308 to +309
tc := testCases[len(testCases)-1] // bip84, accountIndex=1

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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")

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch! Fixed.

starius added 2 commits April 9, 2026 01:31
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.
@starius starius force-pushed the ImportAccountDryRun-side-effects branch from e15120a to a7bdbe9 Compare April 9, 2026 06:31

@Abdulkbk Abdulkbk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

just one non-blocking nit.

Comment thread wallet/import_test.go
w.Lock()

err = w.Unlock(
[]byte("world"), time.After(10*time.Minute),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: 10 mins seem excessive for this test

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants