Skip to content

fix(bdk_electrum_streaming): Keep the highest last active index per notification - #21

Merged
evanlinjin merged 2 commits into
mainfrom
claude/github-issue-3-status-0ewl7a
Sep 1, 2026
Merged

fix(bdk_electrum_streaming): Keep the highest last active index per notification#21
evanlinjin merged 2 commits into
mainfrom
claude/github-issue-3-status-0ewl7a

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Context

Follow-up on #3 ("Fix off-by-one error in last_active_index emission").

The off-by-one from #3 is already fixed. At the time the issue was filed, handle_script_status returned this_index + 1 and that value was emitted directly as the keychain's last active index. Commit 4447f17 ("fix: Off-by-one last_active_indices update", May 2025) changed it to return this_index, and every emission site on main today reports the index of the spk that actually has history. The first commit here adds a state-level regression test that pins that behaviour so it cannot silently regress.

While covering it, I found a live bug with the same consequence (an active spk never gets revealed) — not an off-by-one, and not the one this PR originally described.

This PR was rebased onto main after the "Give the chain and anchors one owner" refactor (#13) and the State::start refactor (#17) landed. Those refactors moved last_active_indices bookkeeping from advance_spk_jobs/an inline notification handler into a unified on_spk_status + poll_spk_jobs. The original bug this PR fixed (advance_spk_jobs folding several job completions into one update via BTreeMap::extend, iterated in script-hash order) no longer exists in that form — but the refactor introduced a different bug with the same symptom, described below.

The bug on current main

on_spk_status stages a keychain's last active index with a plain BTreeMap::insert(k, i) whenever a script hash notification (or subscribe response) names history. Electrum notifies each subscribed script hash independently, in an order unrelated to derivation index — a later-derived spk can be notified before an earlier one. The last notification processed wins, so the staged index can end up lower than the highest spk that actually has history. That index is what reveal_to_target_multi reveals to, so the higher spk stays unrevealed and the wallet does not recognise its txouts as its own.

Separately, poll_spk_jobs had its own write to the same field, derived from mere job completion rather than from a notification naming history. That made it fire for every finished spk job, active or not — including every empty look-ahead spk subscribed alongside the active ones, which is the common case. on_spk_status already stages the index for every spk that actually has history, before its job even runs, so this second write was both redundant for active spks and actively wrong for inactive ones (it would tag an empty look-ahead spk as "active" at whatever index it happened to be, clobbering the real one). Removed.

Changes

test(bdk_electrum_streaming): Cover last_active_indices emission

Adds two state-level tests:

  • last_active_index_is_index_of_active_spk: pins the off-by-one fix (passes on main).
  • last_active_index_is_highest_regardless_of_notification_order: drives a sync where two spks of the same keychain (indices 3 and 4) each have history, and delivers their script-hash notifications with the higher index notified first. Fails on main with left: [("external", 3)], right: [("external", 4)].

fix(bdk_electrum_streaming): Keep the highest last active index per notification

Makes on_spk_status's insert a max-merge instead of an overwrite, and removes the redundant/harmful write in poll_spk_jobs. Passes with both tests above.

Testing

cargo fmt --check, cargo clippy --lib --tests -D warnings, and cargo test (lib + tests/state.rs) all pass.

tests/env.rs could not be run here: its bdk_testenv dev-dependency builds bitcoind, whose build script downloads Bitcoin Core from bitcoincore.org, which this sandbox's egress proxy blocks. That file is untouched by this PR, but it is worth a CI run.


Generated by Claude Code

evanlinjin and others added 2 commits September 1, 2026 12:35
Pin two properties of `last_active_indices` with state-level tests:

- The emitted index must be the derivation index of the spk that has
  history, not the index after it (the off-by-one issue #3 reported).
  Already fixed on `main`; this pins it against a regression.

- The keychain's last active index must be the *highest* of any spks
  that have history, no matter what order the server notifies their
  statuses in. Electrum notifies per-script-hash independently of our
  derivation order, so a later-derived spk can be reported before an
  earlier one. `state.rs`'s `on_spk_status` currently overwrites the
  staged index with whichever notification lands last, so this second
  test fails on `main`: two notifications for indices 4 then 3 leave
  the keychain's index at 3, not 4. Reporting a lower index than the
  actual highest leaves the higher spk unrevealed, so the wallet does
  not recognise its txouts as its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FAFd2PPjDZAfgP35zeQNN
…otification

`on_spk_status` stages a keychain's last active index with a plain
`BTreeMap::insert` whenever a script hash notification (or subscribe
response) names history. Electrum notifies each subscribed script hash
independently, in an order unrelated to derivation index, so a spk
derived later can be notified before one derived earlier. The last
notification processed wins, so the staged index can end up lower than
the highest spk that actually has history.

That index is what `reveal_to_target_multi` reveals to, so the higher
spk stays unrevealed and the wallet does not recognise its txouts as
its own. Merge on the maximum instead.

`poll_spk_jobs` had its own write to the same field, derived from mere
job completion rather than from a notification naming history. That
made it fire for every finished spk job, active or not — including
every empty look-ahead spk subscribed alongside the active ones, which
is the more common case. `on_spk_status` already stages the index for
every spk that actually has history, before its job even runs, so this
second write was both redundant for active spks and actively wrong for
inactive ones. Removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FAFd2PPjDZAfgP35zeQNN
@evanlinjin
evanlinjin force-pushed the claude/github-issue-3-status-0ewl7a branch from 57a7c00 to 40e0695 Compare September 1, 2026 12:37
@evanlinjin evanlinjin changed the title fix(bdk_electrum_streaming): Keep the highest last active index per update fix(bdk_electrum_streaming): Keep the highest last active index per notification Sep 1, 2026
@evanlinjin
evanlinjin merged commit b4b156c into main Sep 1, 2026
evanlinjin added a commit that referenced this pull request Sep 1, 2026
271f195 docs(bdk_electrum_streaming): Fix stale README and Cargo.toml description (志宇)
144e6e4 chore: Bump `bdk_electrum_streaming` to `v0.7.0` (志宇)

Pull request description:

  ## Summary
  - Bumps `bdk_electrum_streaming` from `0.6.0` to `0.7.0` following the `last_active_indices` fix in #21, which changes the emitted result for keychains with multiple active spks.
  - Fixes the crate's README and `Cargo.toml` description, both of which were stale (the README referenced a dependency, `e_electrum_client`, that isn't the crate's actual dependency, `electrum_streaming_client`, and didn't say what the crate does).

  ## Test plan
  - [x] `cargo check -p bdk_electrum_streaming`

  🤖 Generated with [Claude Code](https://claude.com/claude-code)

  https://claude.ai/code/session_017FAFd2PPjDZAfgP35zeQNN

Top commit has no ACKs.

Tree-SHA512: 0e9f85cd5bfa7ec28893990751dc2b3f726fa0b1204a69050e9e98705a54e8d3cf9460c0c141c7861d5b18da8a742e988e47407b7e8f3372af36cf22421bbfae
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.

1 participant