Skip to content

lwwallet: speed up sync by bypassing the linear block scan - #1077

Merged
sputn1ck merged 3 commits into
lightninglabs:mainfrom
guggero:lwwallet-sync-optimization
Jul 31, 2026
Merged

lwwallet: speed up sync by bypassing the linear block scan#1077
sputn1ck merged 3 commits into
lightninglabs:mainfrom
guggero:lwwallet-sync-optimization

Conversation

@guggero

@guggero guggero commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #1073 and is complementary to #1076.

Implements three different speedups:

  • On TipPoller.Start(), fetching the 99 recent block hashes synchronously blocks the whole wallet startup -> moved to background.
  • In EsploraChainService.FilterBlocks, since we're already talking to an address indexer, we can bypass the sequential s.esplora.GetRawBlock() calls by looking up the history of the addresses to scan.
  • On EsploraChainService.Rescan() we do the same by fetching the whole history of the addresses in question.

@sputn1ck
sputn1ck force-pushed the lwwallet-sync-optimization branch from 474b2bc to 5fce2a4 Compare July 31, 2026 11:44
@sputn1ck
sputn1ck marked this pull request as ready for review July 31, 2026 11:46
Copilot AI review requested due to automatic review settings July 31, 2026 11:46

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5fce2a49ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lwwallet/tip_poller.go Outdated
Comment on lines +333 to +338
t.seedHashHistory(height, hash)
if t.runCtx.Err() != nil {
return
}

t.pollLoop()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retry a failed hash-history seed before polling

If any startup raw-header request fails transiently, seedHashHistory returns early but this goroutine still starts pollLoop with a hole in recentHashes. A later reorg that walks across that hole always exits through the new “history is incomplete” branch, and every subsequent poll retries the same walk without ever refilling the missing hash, so the cached tip and all subscriber block events remain stuck indefinitely. Propagate the seed failure and retry or repair the history before beginning normal polling.

AGENTS.md reference: lwwallet/AGENTS.md:L14-L18

Useful? React with 👍 / 👎.

Comment thread lwwallet/esplora_chain.go
Comment on lines +332 to 335
for script := range probes {
refs, err := s.scriptHistoryToHeight(
ctx, []byte(script), minHeight,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Probe script histories with bounded concurrency

During seed recovery, FilterBlocksRequest can contain a recovery window's worth of external and internal addresses (the production default is 100), but this loop serializes an independent HTTP history lookup for every script and potentially every pagination page. On the mobile latency motivating this change, even unused addresses therefore add tens of seconds per call before any result can be returned. Use bounded concurrency, as probeScripts already does with scriptProbeConcurrency, while merging candidates safely.

Useful? React with 👍 / 👎.

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

This PR improves lwwallet sync/startup performance when using the Esplora backend by removing sequential block-body scans in favor of Esplora’s address index, and by moving TipPoller’s hash-history seeding off the critical startup path.

Changes:

  • TipPoller: introduce a component-owned lifecycle context, publish the initial tip immediately, and seed the recent-hash ring asynchronously before starting the poll loop.
  • Esplora chain backend: implement FilterBlocks and (parts of) Rescan using script history + raw-tx fetches, with correctness-preserving fallbacks to block scanning on index inconsistencies.
  • Add targeted tests to pin the non-block-scanning behavior, ordering requirements, and new TipPoller startup/shutdown behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lwwallet/tip_poller.go Adds poller-owned context + async history seeding to unblock wallet startup.
lwwallet/tip_poller_test.go Updates stubs to support content-addressed header lookup across reorgs.
lwwallet/tip_poller_reorg_test.go Adds tests for “no polling before seed complete” and Stop cancelling in-flight seed requests.
lwwallet/esplora_filterblocks_test.go New test suite validating index-based FilterBlocks/Rescan behavior and fallbacks.
lwwallet/esplora_chain.go Implements index-based FilterBlocks and index-assisted Rescan with dependency ordering.
lwwallet/chain_backend_reorg_test.go Improves fake chain fixtures and adds timing test ensuring TipPoller Start doesn’t block on seeding.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lwwallet/esplora_chain.go
Comment on lines +465 to +469
all = append(all, refs...)
last := refs[len(refs)-1]
if int32(last.Status.BlockHeight) <= minHeight {
return all, nil
}
guggero and others added 3 commits July 31, 2026 15:50
Move the sequential hash-history walk off the synchronous startup
path. Keep the poll loop behind a fully seeded ring, so reorg handling
never observes partial retained history.

Retry transient seed failures with bounded exponential backoff until
the service stops. Bind requests and retry timers to the component
context, so shutdown cancels the entire operation promptly.

Cover responsive startup, a reorg racing the seed, retry recovery, and
cancellation.

Co-authored-by: sputn1ck <kon@kon.ninja>
Use Esplora script histories to identify candidate transactions for
FilterBlocks and Rescan instead of downloading every block body.
Preserve canonical matching and ordering, and validate indexed block
identities before advancing wallet state.

Fall back to full block scans when history is truncated, inconsistent,
or cannot represent a watched outpoint address. Page through every
transaction at the minimum requested height before stopping.

Cover spent addresses, pagination boundaries, ordering, reorg
conflicts, fallbacks, cancellation, and the empty index contract.

Co-authored-by: sputn1ck <kon@kon.ninja>
Document that an empty script history cannot be distinguished from a
lagging index. Explain the recovery boundary and require a trusted,
fully indexed Esplora endpoint for wallet rescans.
@sputn1ck
sputn1ck force-pushed the lwwallet-sync-optimization branch from 5fce2a4 to 7d514dc Compare July 31, 2026 13:56

Copy link
Copy Markdown
Member

Update after taking over the PR from @guggero. The branch is now organized as three atomic commits; Guggero remains the author of both implementation commits, with the follow-up fixes recorded as co-authored work.

What this PR changes, and why

1. Seed the tip poller's reorg history without blocking wallet startup

Previously, TipPoller.Start synchronously walked roughly 100 headers before returning. Since wallet startup waits for the poller, that could add many seconds of serial Esplora requests on a mobile connection.

The history walk now runs under the poller's component-owned goroutine/context, so Start can publish the current tip and return promptly. The polling loop still does not begin until the complete retained hash ring has been seeded, which means reorg handling never observes a partial ring.

We also changed transient seed failures from "log and continue polling" to bounded exponential retries. This matters because entering the polling loop with a gap would make a later reorg look like internal corruption and disable reorg handling until the missing height aged out of the ring. Requests and retry timers use runCtx, so Stop cancels them promptly.

2. Use Esplora's address index for FilterBlocks and Rescan

The old path downloaded and parsed every block body in the recovery range. That defeats the purpose of using an indexed Esplora backend and is the dominant cost of restoring an old wallet.

The new path:

  • queries paginated script histories for derived wallet addresses and watched outpoints;
  • keeps only confirmed candidates in the requested height range;
  • fetches and runs candidates through the existing wallet matching logic;
  • validates each indexed height/hash against the canonical chain before advancing wallet state;
  • deduplicates candidates and preserves parent-before-child transaction ordering;
  • returns the lowest matching block from FilterBlocks; and
  • keeps the per-height hash/header walk required by btcwallet, while avoiding raw block downloads on the indexed fast path.

The history pagination stop condition is deliberately strict: we stop only after the oldest page entry is below the minimum requested height. This ensures we fetch every page when more than one page of transactions shares the boundary height.

3. Fail safely when the index answer cannot be trusted

Both FilterBlocks and Rescan fall back to the original full-block scan when history is truncated at the safety cap, an indexed block identity conflicts with the canonical chain, history/transaction retrieval fails, or a watched outpoint's script cannot be represented as an address.

That last watched-outpoint case previously used continue, which silently skipped a potentially relevant spend. Routing it to the canonical scan preserves the old behavior instead.

Cancellation is not treated as an index failure: context cancellation is returned directly rather than starting an expensive fallback scan during shutdown.

4. Document the remaining index-trust boundary

A successful empty script-history response is indistinguishable from a lagging-but-otherwise-valid Esplora index. Failing closed on every empty answer would require scanning every height without wallet activity, negating the optimization entirely.

The package docs now state this invariant explicitly: recovery trusts the completeness of successful index responses, and a lagging index can cause PutSyncedTo to advance past omitted activity permanently. Wallet recovery should therefore use a trusted, fully indexed Esplora instance, ideally one under the operator's control.

Test coverage added

The new tests cover:

  • startup returning without waiting for the seed walk;
  • polling waiting for a complete ring;
  • a reorg racing startup;
  • recovery after a transient seed failure;
  • cancellation of in-flight seed requests and retry timers;
  • fully spent addresses, unconfirmed entries, and out-of-range entries;
  • multi-page histories, including more than one page at the minimum height;
  • watched-outpoint spends;
  • false index candidates and canonical block-hash conflicts;
  • parent-before-child transaction ordering;
  • history-cap and unsupported-address fallbacks in both scan paths;
  • cancellation without fallback;
  • indexed Rescan notification behavior; and
  • a deliberately incomplete fake index documenting the successful-empty trust contract.

The fake Esplora uses the service runCtx, so the cancellation branches are exercised, and fast-path tests reject unexpected block-body requests.

Validation

Passed locally:

  • make fmt-changed-check
  • make lint-changed-local
  • make tidy-module-check
  • make build
  • make commitmsg-lint range="origin/main..HEAD"
  • go test ./lwwallet -count=1
  • targeted go test -race for the new retry, reorg, fallback, pagination, index-contract, and cancellation cases
  • go test ./lwwallet independently on each implementation commit

The remaining acknowledged follow-ups are performance-only: bounding/caching concurrent history requests and smoothing the maxScriptTxPages fallback cliff. Both currently preserve correctness by falling back to canonical block scanning.

@sputn1ck sputn1ck added the backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch label Jul 31, 2026
@sputn1ck
sputn1ck merged commit b8fb65c into lightninglabs:main Jul 31, 2026
36 of 38 checks passed
@github-actions

Copy link
Copy Markdown

Successfully created backport PR for v0.1.x-branch:

sputn1ck added a commit that referenced this pull request Jul 31, 2026
…ranch

[v0.1.x-branch] Backport #1077: lwwallet: speed up sync by bypassing the linear block scan
@guggero
guggero deleted the lwwallet-sync-optimization branch August 1, 2026 07:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lwwallet: avoid sequential block scan with esplora client

3 participants