Skip to content

chore(dash-spv): refresh masternode seed files#153

Open
github-actions[bot] wants to merge 43 commits into
v0.42-devfrom
chore/refresh-masternode-seeds
Open

chore(dash-spv): refresh masternode seed files#153
github-actions[bot] wants to merge 43 commits into
v0.42-devfrom
chore/refresh-masternode-seeds

Conversation

@github-actions

Copy link
Copy Markdown

Weekly automated refresh of the hardcoded masternode IP lists
embedded in dash-spv (dash-network-seeds/seeds/mainnet.txt,
dash-network-seeds/seeds/testnet.txt).

Generated by .github/workflows/update-masternode-seeds.yml
via the masternode-seeds-fetcher crate, which fetches the
current masternode list from the Dash P2P network using a
getmnlistd request against a peer discovered through DNS
seeds and the existing seed files.

ZocoLini and others added 30 commits May 9, 2026 01:24
…ashpay#747)

* refactor(key-wallet)!: remove `MnemonicWithPassphrase` wallet type

Drops the entire passphrase-as-callback feature: callers can no longer
construct a wallet that retains a mnemonic and applies a BIP39 passphrase
on-demand. Removed across the workspace:

- `WalletType::MnemonicWithPassphrase` variant + Zeroize arm
- `Wallet::from_mnemonic_with_passphrase` constructor
- `add_account_with_passphrase`, `add_bls_account_with_passphrase`,
  `add_eddsa_account_with_passphrase` on `Wallet`
- `derive_extended_private_key_with_passphrase`,
  `create_accounts_with_passphrase_from_options`,
  `create_special_purpose_accounts_with_passphrase`,
  `needs_passphrase`, and `root_extended_priv_key_with_callback` helpers
- `add_managed_*_with_passphrase` on `ManagedAccountOperations`
- `passphrase` parameter from `WalletManager::create_wallet_from_mnemonic`
  and `create_wallet_from_mnemonic_return_serialized_bytes`
- `passphrase` parameter from FFI `wallet_create_from_mnemonic*`,
  `wallet_manager_add_wallet_from_mnemonic*`,
  `wallet_manager_add_wallet_from_mnemonic_return_serialized_bytes`
- All passphrase-only tests (`test_wallet_with_passphrase`,
  `test_passphrase_edge_cases`, the `passphrase_test` module, FFI
  `test_passphrase_wallets.rs`, etc.)

Breaking change: serialized wallets containing the
`MnemonicWithPassphrase` variant can no longer be deserialized, and the
FFI `passphrase` parameter is gone — callers must update their code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(key-wallet-ffi): regenerate FFI_API.md after passphrase removal

Auto-generated docs were stale after removing the `passphrase` parameter
from `wallet_create_from_mnemonic*` and `wallet_manager_add_wallet_from_mnemonic*`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: address review comments after passphrase removal

- Distinguish ExternalSignable from WatchOnly in
  derive_extended_private_key error path so callers see why each variant
  rejects the request.
- Drop the stale "passphrase 'pass1'" debug prints in
  debug_wallet_add.rs that no longer reflect what the test does.
- Remove the obsolete bug-comment and "// No passphrase" annotation in
  wallet_manager_tests.rs — the bug and the parameter both no longer
  exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ashpay#743)

When a header batch lands during an in-flight `Incremental` `MnListDiff`, intermediate `BlockHeadersStored` events get dropped by the `has_pending_requests` guard, and the tick's `current_height < block_header_tip_height` check is false once `Incremental` catches up. If the cycle's mining window opens and closes inside that batch, the catch-up branch in `next_pipeline_mode` never fires and the cycle's rotated quorums stay unvalidated until a later block extends past the window.

Re-evaluate `next_pipeline_mode` at the advanced tip when `Incremental` completes and fire the QRInfo immediately if the gate picks `QuorumValidation`. Fixes a race in `test_masternode_list_sync_with_quorum_rotation` where `dashd` delivered headers in two batches straddling the third DKG cycle's mining window.
* test(dash-spv): cover spending mempool change and unconfirmed incoming UTXOs

Adds two regression tests in `tests/dashd_sync/tests_transaction_builder.rs`
that drive `ManagedWalletInfo::build_and_sign_transaction` end-to-end against
a real dashd regtest node, broadcasting via the SPV client.

Both tests use a fresh-mnemonic wallet (EMPTY/SECONDARY) so the SPV side has
no preexisting confirmed UTXOs from the test chain that would mask the bug.

- test_spend_unconfirmed_change_balance: confirms the wallet reports trusted
  mempool change as spendable, then fails to actually spend it through coin
  selection (the filter ignores `is_trusted`).
- test_spend_unconfirmed_incoming_balance: confirms the wallet reports
  unconfirmed incoming as spendable, then fails to spend it.

Both tests are red against the current coin selector and document the user-
reported symptom ("balance shows up but cannot be spent").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(key-wallet): defer CoinSelector candidate filter to Utxo::is_spendable

`CoinSelector` carried its own confirmation policy
(`min_confirmations`, `include_unconfirmed`) and filtered candidates
with `(include_unconfirmed || is_confirmed || is_instantlocked)` plus
a `current_height - utxo.height >= min_confirmations` depth check.
Two consequences:

  1. `is_trusted` was missing from the OR — mempool change from a tx
     the wallet itself authored was rejected, even though the balance
     UI shows it as spendable. Users hit this as
     `CoinSelection(NoUtxosAvailable)` immediately after sending.
  2. The depth check used `>= 1`, which silently rejected a UTXO mined
     into the wallet's tip block (depth 0, but 1 confirmation in the
     usual sense), and gated all unconfirmed incoming through the same
     path.

The `Utxo` itself already has `is_spendable(current_height)` — which
checks not-locked and coinbase maturity — and the wallet decides what
to put in the UTXO map (with `is_confirmed`, `is_instantlocked`,
`is_trusted` flags reflecting its policy). The coin selector having a
*second*, divergent policy on top is what produced the bug.

Drop the duplicated machinery: remove `min_confirmations`,
`include_unconfirmed`, and the per-call helpers. Both filter sites
now defer to `Utxo::is_spendable`, so coin selection accepts every
UTXO the wallet tracks as spendable — confirmed, IS-locked, trusted
self-send change, and incoming mempool alike.

Tightens `tests_transaction_builder.rs` accordingly: the workaround
that mined an extra "bump" tx so `last_processed_height` advanced past
the funding UTXO is no longer needed.

All 482 key-wallet unit tests and 26 dashd_sync integration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(dash-spv): integrations tests cleanup

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…shpay#746)

* fix(dash-spv): preserve in-progress sync state on peer disconnect

`BlocksManager` and `FiltersManager` wiped their pipelines on every disconnect, which raced with the previous cycle's `BlockProcessed` events still in flight and left `pending_blocks` stuck above zero so `SyncComplete` never fired.

Replace the trait's `clear_in_flight_state` with a single `on_disconnect` hook. Each manager only invalidates state tied to the dead peer. `BlocksManager` and `FiltersManager` requeue their in-flight `getdata` / `getcfilters` slots so the next `send_pending` reissues them on the new peer, and `start_sync` resumes the preserved batches. The wallet-rescan path in `FiltersManager::tick` calls a manager-internal `reset_for_rescan` for the case that genuinely wants a full wipe.

* fix(dash-spv): cancel pending entry when late `cfilter` completes a requeued batch

After `requeue_in_flight()` moves a batch's `start_height` from `coordinator.in_flight` back to `coordinator.pending`, a buffered `CFilter` from the disconnected peer can still reach `receive_with_data` and complete the batch. The tracker is removed but `coordinator.receive` returns `false` (the key is in `pending`, not `in_flight`), leaving the key orphaned in `pending`. The next `send_pending` pops it, finds no tracker, and aborts sync with `SyncError::InvalidState`.

Add `DownloadCoordinator::cancel_pending` and call it from `receive_with_data` when `receive` reports the key was not in flight.

Addresses CodeRabbit review comment on PR dashpay#746
dashpay#746 (comment)
* test(dash-spv): add masternode integration tests

Adds a `dashd_masternode` integration test suite that runs against a real dashd regtest network with multiple masternodes, plus a new `masternode_network` module in `test_utils` that manages multi-node dashd lifecycles, DKG cycles, mocktime advancement, and ChainLock signing.

The new suite complements the existing `dashd_sync` tests by exercising masternode-specific behavior. `tests_sync.rs` covers masternode list sync, restart, new-block extension, quorum rotation, and a full end-to-end scenario. `tests_instantsend.rs` covers InstantSend lock formation, behavior across quorum rotation, and the case where the `islock` arrives before the transaction.

* fix(dash-spv): check current state before awaiting in `wait_for_masternode_sync`

`watch::Receiver::changed()` only wakes on future updates, so callers entering after masternode sync was already `Synced` would wait until timeout. Inspect the current value via `borrow_and_update()` first and return early when already synced.

Addresses CodeRabbit review comment on PR dashpay#740
dashpay#740 (comment)

* test(dash-spv): fail fast when `protx update_service` fails during bootstrap

The masternode network setup depends on every node having its real P2P port registered, otherwise dashd can't form quorum connections and downstream DKG/IS sessions fail with hard-to-trace timeouts. Collect any RPC failures during the per-masternode `protx update_service` loop and panic with the affected datadirs once the loop is done so all failures are surfaced together.

Addresses CodeRabbit review comment on PR dashpay#740
dashpay#740 (comment)
dashpay#751)

`dashpay#740` moved `create_test_wallet` from `dashd_sync/setup.rs` to `dash_spv::test_utils` and changed `setup.rs` to a private re-import. `dashpay#748` was authored against an earlier base where `setup.rs` still defined a local `pub(super) fn create_test_wallet`, so the new tests in `tests_transaction.rs` imported it via `super::setup`. Each PR was green against its own base, but their merged state fails to compile because the `super::setup` import is now private.

Switch the import to `dash_spv::test_utils`, matching `tests_basic.rs`, `tests_mempool.rs`, `tests_multi_wallet.rs`, and `tests_restart.rs`.
Move `LoggingEventHandler` out of the CLI binary and into `dash-spv` itself, then have `DashSpvClient::new` prepend it to the consumer-supplied handler list. Every consumer (CLI, FFI, integration tests, future embedders) now gets event log output for free as soon as a `tracing` subscriber is installed, instead of each one re-implementing the same bridge.

The handler is `pub(crate)` since consumers never need to construct or reference it. Per-event silencing is available through the standard `tracing` target filter `dash_spv::client::event_handler=warn`, so no opt-out flag is needed.
Co-authored-by: QuantumExplorer <11468583+QuantumExplorer@users.noreply.github.com>
)

Drop `LoggingEventHandler` and the auto-prepend in `DashSpvClient::new`. Instead, have `spawn_broadcast_monitor` log every received event via `tracing::info!("{}: {}", name, event)` before consumer handlers run, and have `spawn_progress_monitor` log progress directly alongside its `on_progress` dispatch. Add `Display` impls on `SyncEvent`, `NetworkEvent`, and `WalletEvent` that delegate to their existing `description()` so the helper can format generically with an `E: Display` bound.
…ashpay#754)

* fix(dash-spv): use `committed_height` guard in `handle_new_filter_headers`

The guard `stored_height < filter_header_tip_height` fails to trigger scanning when filters are fully stored but not yet committed to wallet. After a restart where `stored_height` equals the tip, the guard evaluates to false and the manager silently stalls. Using `committed_height` correctly detects uncommitted work.

Also conditionally skip `send_pending` when `stored_height` already covers the tip, avoiding unnecessary download operations when only scan work is needed.

* fix(dash-spv): make `filter_header_tip_height` monotonic

`start_sync` calls `update_filter_header_tip_height(stored_filters_tip)` on reconnection, which can be lower than the tip already set by `handle_new_filter_headers`. This regresses the download target and causes the pipeline to lose knowledge of available filter headers.

Apply the same monotonic guard pattern already used by `update_target_height`.

* fix(dash-spv): prevent `send_pending` from losing batches on missing headers

`take_pending` removes batches from the coordinator's pending queue. If `get_header` then returns `None` (header not yet stored), the batch is permanently lost because it's neither pending nor in-flight. Re-queue via `coordinator.enqueue` and continue to the next batch instead.

Also add a second `try_commit_batches` pass after `scan_ready_batches` in `try_process_batch` so that single-block incremental updates are committed in the same tick rather than waiting for the next one.
…ashpay#618)

Add mutable access to `FFIManagedCoreAccount` via `inner_mut()` and
expose a function to set or clear transaction labels by txid.

Also add comprehensive tests for:
- `managed_core_account_get_transactions` with real transaction data
- `managed_core_account_free_transactions` verifying no crash on free
- Label round-trip (set, read back, clear, verify cleared)
- Error cases (null account, missing txid)
* feat(key-wallet): add chainlock handling to the wallet

Sets up the wallet-layer foundation for chainlock-driven transaction finalization, surfacing chainlock finality and the signing proof to consumers through wallet events.

- `WalletMetadata::last_applied_chain_lock` persists the highest `ChainLock` applied, establishing the wallet's finality boundary.
- `WalletEvent::TransactionsChainlocked` carries the chainlock and the net-new finalized txids per account.
- `WalletEvent::BlockProcessed` carries `chain_lock: Option<ChainLock>`, `Some` only when the block is at or below the wallet's finality boundary.

* fix(dash-spv): re-run `verify_block_hash` before promoting a deferred ChainLock

`on_masternode_ready` was promoting `pending_validation` to `best_chainlock` after a successful BLS signature check only, but the cached chainlock's block-hash had been verified earlier under `process_chainlock`'s permissive rule: when the header for that height is still missing, `verify_block_hash` returns `true` so the chainlock isn't dropped before masternode state arrives. By the time `on_masternode_ready` fires, the header has usually resolved, and if its hash disagrees with the chainlock's claim the deferred path would persist a chainlock the local chain doesn't match.

Re-run `verify_block_hash` on the pending chainlock before calling `validate_signature`. Both must pass for promotion; a mismatch is counted as invalid and dropped, same as the live path in `process_chainlock`.
…#765)

Replace the raw `[u8; 33]` field with `dashcore::PublicKey` so the event payload carries a validated curve point instead of opaque bytes. The projection in `DerivedAddress::from_info` now parses via `PublicKey::from_slice` and logs a warn on failure, matching the existing skip-on-bad-input pattern.

The FFI surface in `dash-spv-ffi` keeps its `[u8; 33]` field for ABI stability and serializes the parsed key back to compressed bytes at the boundary.
…undingType and DerivedAddress (dashpay#761)

Expose optional serde Serialize/Deserialize on two types so downstream
consumers can drop hand-rolled adapters:

  * `key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType`
    (enum) — derives are gated on the existing `serde` feature.
  * `key_wallet_manager::DerivedAddress` (struct) — `key-wallet-manager`
    previously had no `serde` feature, so this adds one wired to
    `key-wallet/serde` and `dashcore/serde`. The struct's `public_key`
    field is `dashcore::PublicKey`, which already implements serde under
    the `dashcore/serde` feature, so no custom adapter is needed.

Motivation: dashpay/platform PR #3637 (rs-platform-wallet serde support)
currently works around the missing derives with a custom
`serde_adapters::asset_lock_funding_type` module and a
`#[serde(skip)]` on a `PlatformAddressChangeSet::addresses_derived`
field. Once this lands, both workarounds become deletable.

Round-trip test (`derived_address_serde_json_roundtrip`) locks down the
JSON wire shape for `DerivedAddress` so future regressions are caught.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ashpay#758)

Fold the inherent `description() -> String` methods on `SyncEvent`, `NetworkEvent`, and `WalletEvent` into their `Display::fmt` bodies, and update every caller to format the event directly (`{event}` instead of `{event.description()}`). `write!` replaces the per-arm `format!` to avoid the intermediate `String` allocation.

Output is byte-for-byte identical. No new public API, just the existing `Display` impl carrying the formatting instead of a parallel inherent method.
…#753)

Tokio's `AsyncReadExt::read` consumes `WouldBlock` inside the runtime, so the future never resolves with that error kind. Read timeouts are imposed via `tokio::time::timeout(..)` and surface as `Elapsed`, not `io::Error::TimedOut`. The four `match` blocks in `Peer::receive_message`'s framing loop kept arms for both kinds, all of which were dead code.

Drop the eight unreachable arms. The remaining `Ok(0)`, `Ok(_)`, `ConnectionAborted`/`ConnectionReset`, and catch-all `Err(e)` arms continue to surface real errors, with the catch-all converting any unanticipated `io::Error` into `NetworkError::ConnectionFailed` rather than silently masking it.
`Swatinem/rust-cache@v2` defaults to `cache-bin: true`, which caches `~/.cargo/bin`. On macOS aarch64 the rustup proxies are hardlinks to a single binary, and after a partial (prefix-match) cache restore `cargo` can end up pointing to the `rustup-init` installer instead of the rustup proxy, making every `cargo test` invocation fail with `error: unexpected argument 'test' found`.

Disable bin caching so the toolchain is always reinstalled by `dtolnay/rust-toolchain@stable`. Bump `prefix-key` to `v1-rust` to invalidate the existing poisoned caches, since GitHub Actions cache rejects saves to an existing key and prefix-match restore would otherwise keep pulling the bad tar.
…ce (dashpay#769)

* feat(key-wallet): ApplyChainLockOutcome surfaces metadata advance flag

Replace the BTreeMap return of `WalletInfoInterface::apply_chain_lock`
with an explicit `ApplyChainLockOutcome { per_account, metadata_advanced }`
so the wallet-manager-level emitter can fire one event per effect:
`TransactionsChainlocked` when records were promoted, and a separate
event whenever `last_applied_chain_lock` advanced (added in the next
commit).

The two effects fire independently — a quiescent wallet that sees a
chainlock above its history still advances the finality boundary even
though no record is promoted. Durable consumers that persist
`last_applied_chain_lock` (e.g. the platform-wallet bridge that uses
it to build a `ChainAssetLockProof` for `InBlock` asset-lock TXs on
restart) need a signal on every boundary advance, not just on the
promotion path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(key-wallet-manager): emit ChainLockApplied event on metadata advance

Add a new `WalletEvent::ChainLockApplied { wallet_id, chain_lock }`
variant that fires whenever a wallet's `last_applied_chain_lock`
metadata advances forward (or moves from `None` to `Some`), and route
it through the FFI dispatcher.

`apply_chain_lock` now reads `ApplyChainLockOutcome` from the wallet
info and emits up to two events per wallet, in this order:

1. `ChainLockApplied` when the metadata advanced, so persisters that
   need to mirror `last_applied_chain_lock` to durable storage have a
   single hook regardless of whether any record was promoted. This is
   the gap the platform-wallet bridge had to live with: a chainlock
   that advanced the boundary without promoting anything was
   completely invisible, and the persisted `last_applied_chain_lock`
   went stale, which broke restart-time `ChainAssetLockProof`
   construction for `InBlock` asset-lock TXs.
2. `TransactionsChainlocked` when at least one record was promoted
   from `InBlock` to `InChainLockedBlock` (unchanged contract).

Ordering matters: `ChainLockApplied` fires FIRST so a persister
listening to both events can write durable metadata before the
promotion record.

FFI: adds `OnWalletChainLockAppliedCallback` and an
`on_chain_lock_applied` field appended to `FFIWalletEventCallbacks`
(before `user_data`) so existing offsets stay stable for C consumers.

Event-tests updated to reflect the new two-event contract on chainlock
promotion, the metadata-only advance path, and the higher-replay path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dash-spv-ffi): preserve user_data ABI offset + test ChainLockApplied dispatch

CodeRabbit caught that the new `on_chain_lock_applied` field was inserted
before `user_data` in `FFIWalletEventCallbacks`, which shifted
`user_data`'s byte offset and contradicted the layout-stability claim in
the doc comment — C-side consumers hand-allocating this struct from older
headers (i.e. without regenerating via cbindgen) rely on `user_data`
staying where it was. Move the new field strictly to the end so every
prior field — including `user_data` — keeps its previous offset, and
rewrite the field's doc comment to reflect the new placement and the
actual ABI guarantee.

Also adds the `ChainLockApplied` dispatch unit test CodeRabbit suggested
as a nice-to-have, sitting alongside the existing dispatch tests at the
bottom of `callbacks.rs`. The test registers an `extern "C"` callback,
fires a `WalletEvent::ChainLockApplied` carrying a synthetic
`ChainLock::dummy(777)`, and asserts the callback received `cl_height ==
777` — pinning the dispatch path against silent regressions.

Refs: dashpay#769 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dash-spv-ffi): wire on_chain_lock_applied into dashd_sync test fixture

Appending `on_chain_lock_applied` to `FFIWalletEventCallbacks` broke the
integration-test fixture in `dashd_sync/callbacks.rs`, which constructs
the struct with all fields named explicitly (no `..Default::default()`
spread). CI under `cargo test -p dash-spv-ffi --all-features` failed
with `error[E0063]: missing field on_chain_lock_applied`, which
cascaded into every job that builds the FFI test suite (ffi matrix,
pre-commit, address-sanitizer).

The new wallet-event tests don't need this callback, so wire it as
`None` — same shape as the freshly-added `on_transactions_chainlocked`
slot right above it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: rustfmt over-long assert_eq! lines in event_tests

Two `assert_eq!` calls added earlier on this branch (commits 3a6e452 /
de28922) exceed the workspace rustfmt width and now fail the
`cargo fmt` pre-commit hook on macOS/Ubuntu/Windows. Straight
`cargo fmt --all` output — no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(key-wallet-manager): fold ChainLockApplied into ChainLockProcessed

Per @xdustinface's review on dashpay#769: keep chainlock fan-out atomic by
collapsing the two-event split (`ChainLockApplied` +
`TransactionsChainlocked`) into a single
`WalletEvent::ChainLockProcessed`, fired whenever the wallet's
`last_applied_chain_lock` advances. Net-new per-account promotions
ride along in `locked_transactions` — possibly empty when the chainlock
advanced the metadata boundary without promoting any record (durable
consumers that persist the chainlock proof still observe those
empty-promotion events).

Surface changes:

* `WalletEvent::TransactionsChainlocked` → `WalletEvent::ChainLockProcessed`
* `ApplyChainLockOutcome.per_account` → `locked_transactions` (matching
  the event field name; outcome and event speak the same vocabulary)
* `WalletEvent::ChainLockApplied` removed entirely
* `OnWalletTransactionsChainlockedCallback` →
  `OnWalletChainLockProcessedCallback`, signature unchanged
* `FFIWalletEventCallbacks.on_transactions_chainlocked` →
  `on_chain_lock_processed`; the separate `on_chain_lock_applied` slot
  is gone, so `FFIWalletEventCallbacks` shrinks by one callback pointer
  (a hard ABI break for the unreleased addition, fine since
  v0.42 hasn't shipped)
* `ffi_cli`'s `on_wallet_transactions_chainlocked` printer renamed
  `on_wallet_chain_lock_processed`; emits "[Wallet] ChainLock processed"

Emission semantics: `process_block.rs` now sends one event per wallet
per chainlock, gated on `outcome.metadata_advanced`. Replays of the
same chainlock height are silent. Tests in
`key-wallet-manager/src/event_tests.rs` and
`key-wallet/src/tests/keep_finalized_transactions_tests.rs` updated to
the merged shape, including the empty-`locked_transactions` case for
chainlocks that advance the boundary without promoting any record.

Dispatch test coverage strengthened per CodeRabbit's nitpick on
dashpay#769#discussion (1203-1218): replaces the height-only assertion with
two tests at the bottom of `dash-spv-ffi/src/callbacks.rs`:

* `test_chain_lock_processed_dispatch_round_trips_every_field` —
  registers an `extern "C"` callback, captures every wired argument
  into a typed `Captured` struct, fires a `ChainLockProcessed` with
  two distinct accounts (one txid each), and asserts wallet_id
  hex-encoding, height, 32-byte block hash, 96-byte signature, and
  `finalized_count == 2` (counts (account, txid) pairs, not accounts).
* `test_chain_lock_processed_dispatch_fires_with_empty_promotions` —
  empty `locked_transactions` must still fire the callback with
  `finalized_count == 0`; pins the contract for durable consumers that
  persist the chainlock proof even when no record was promoted.

Closes the design pushback on dashpay#769; addresses the dispatch-test
nitpick in the same review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dashpay#773)

`dash/embedded` has been non-buildable since dashpay#521 dropped `no-std` support from `dashcore`: it requests a `dashcore` `no-std` feature that no longer exists, so its committed `Cargo.lock` can no longer be regenerated. That stale lockfile was the source of Dependabot security alerts (`linked_list_allocator` 0.8.11, RUSTSEC-2022-0063 out-of-bounds writes, pulled via the abandoned `alloc-cortex-m`, plus the yanked `core2`). The example is not a member of the root workspace and is not built in CI, so it is dead code.

`fuzz` is a member of the root workspace, so cargo always resolves it against the gitignored root `Cargo.lock`. The committed `fuzz/Cargo.lock` is never read by any build (confirmed via `cargo locate-project --workspace`) and only served to surface Dependabot advisories (`atty`, `ansi_term`, `memmap`) for a file nothing uses.

Removing both eliminates the Dependabot alerts at their source with no behavior change.
…ashpay#766)

The helper's `BlockProcessed { chain_lock: Some(_), .. }` branch returned the tx's own block height instead of the chainlock's height, which broke `test_chainlock_promotes_in_block_tx` whenever the SPV client processed the tx's block after the chainlock had already arrived (the common ordering when filter sync runs ahead of block delivery). In that case the wallet emits `BlockProcessed` with the chainlock attached, the helper returned the tx block height, and the test's `promoted_at >= cl_height` assertion failed even though the tx was correctly chainlocked.
…h` (dashpay#772)

`DashSpvClient::run()` took an external `CancellationToken` and the client separately tracked `running: Arc<RwLock<bool>>`, two mechanisms for one concept. Shutdown also relied on the `running` flag being observed only on the next 100ms `sync_coordinator` tick.

Both are now a single `Arc<watch::Sender<bool>>`: `true` while running, `false` once a stop is requested. `run()` drops its parameter, subscribes before the internal `start()`, and breaks its loop immediately when the value flips, so `stop()` is an event rather than a poll. `start()`/`stop()` use `send_replace`, so they are correct with zero receivers (the normal state during `run -> stop -> run` and when `stop()` is called without `run()`). `is_running()` keeps its signature, reading the watch via `borrow()`.

`stop()` now flips the state before tearing down the sync coordinator, so a concurrent `run()` loop wakes and exits before it can lock the coordinator again. This removes the window where a tick could race the shutdown.
* feat(dash-spv): log version with git commit for dev builds

Add a `build.rs` that captures the git commit, a dirty flag, and whether `HEAD` is at a `v*` release tag, exposed through a `version_info()` helper next to `VERSION`. `DashSpvClient::new()` logs it once on creation. Tagged clean builds render just `dash-spv <version>`, while development builds include the commit (with a `-dirty` marker for uncommitted changes) so they are recognizable as non-release. Falls back to the bare version when git is unavailable, such as packaged builds.

* fix(dash-spv): watch active branch ref in `build.rs` rerun-if-changed

`.git/HEAD` only changes when switching branches, not when committing on the current one, so watching `HEAD`/`index`/`packed-refs` could leave `DASH_SPV_GIT_HASH` stale across metadata-driven rebuilds. Also watch the symbolic HEAD target's ref file.

Addresses CodeRabbit review comment on PR dashpay#770
dashpay#770 (comment)

* fix(dash-spv): resolve git dirty state via compile-time macro

`DASH_SPV_GIT_DIRTY` was baked by `build.rs`, but `git status` reflects the whole repository while a build script only reruns when one of its declared `rerun-if-changed` paths changes. An unstaged edit under `dash-spv`, a sibling crate like `key-wallet`, or `Cargo.toml` rebuilds the crate without rerunning the script, so the flag kept its cached value until the next stage or commit. Widening the watched paths cannot cover the unbounded working tree, and forcing the script to rerun every build recompiles the crate on every build (`rust-lang/cargo#3404`).

A procedural macro is expanded as part of compiling the invoking crate, so it re-evaluates exactly when the crate is recompiled, which is precisely when an unstaged edit (here or in a dependency) takes effect, and not at all on a no-op build, so there is no per-build cost. The new `git-state` crate exposes `git_dirty!()`, which runs `git status` against `CARGO_MANIFEST_DIR` at expansion and degrades to `false` when git or the source tree is unavailable. `build.rs` keeps emitting the hash and tag, and its `.git` watching now doubles as the trigger that recompiles the crate (and re-runs the macro) on commit, keeping the dirty flag correct there too.

The crate is named generically rather than after dash-spv since the macro is repository-agnostic.

Addresses ZocoLini review comment on PR dashpay#770
dashpay#770 (comment)

* ci: exclude `git-state` crate from test groups

`verify-groups` fails when a workspace crate is in neither a group nor the excluded list. `git-state` is a compile-time proc-macro with no test target. Its behavior is covered transitively by the `dash-spv` `version_info` test, so it goes in the excluded section with a reason, like the other non-unit-tested crates.
Co-authored-by: QuantumExplorer <11468583+QuantumExplorer@users.noreply.github.com>
Prepare the `v0.42.0` release.
…dashpay#778)

* chore: migrate from `vX.Y-dev` branching model to single `dev` branch

Switch the development model away from per-release `vX.Y-dev` branches (`v0.40-dev`, `v0.41-dev`, `v0.42-dev`, each going stale after its release) to a single permanent `dev` branch as the default for active development, with `master` renamed to `main` to track the latest tagged release. Release branches (`chore/release-vX.Y.Z`) are still cut from `dev` per release for the review window, then merged back.

Updates the file-level references that hardcode the old model:

- CI workflow triggers (`pre-commit`, `rust`, `sanitizer`, `conflict-check`) now fire on pushes to `main` / `dev` instead of `master` / `v**-dev`.
- `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `README.md` describe the new branching model and target branches.
- README codecov badges point at `branch/main` and `branch/dev`.

The actual GitHub-side actions (rename `master` -> `main`, rename `v0.42-dev` -> `dev`, change default branch, update branch-protection rules) are repo settings and must be performed by an admin separately. Order matters: tag `v0.42.0` from `v0.42-dev` first, then perform the GitHub renames, then merge this PR against `dev`.

To fix it up locally just run:

```
git branch -m v0.42-dev dev
git fetch origin
git branch -u origin/dev dev
git remote set-head origin -a
```

* Update CONTRIBUTING.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Open the `v0.43.0` dev cycle and remove the extra mention of the version in the `ARCHITECTURE.md` leaving us with only one number to bump on version bumps.
…ons` (dashpay#768)

* fix(dash-spv): defer `Syncing` transition in `send_qrinfo_for_tip` until after send

A `BlockHeaderSyncComplete` event delivered while `MasternodesManager` is in `WaitingForConnections` reaches `send_qrinfo_for_tip` with no peers connected. The previous order set state to `Syncing` before `requests.request_qr_info(...)?` propagated `Err("No connected peers")`, leaving the manager in `Syncing` with `qrinfo_in_flight = None`. `tick` only retries when `qrinfo_in_flight.is_some()` so the manager was stuck until the next external event.

Reorder so the state mutation only happens after the send and the in-flight bookkeeping have committed. On send failure the manager remains in `WaitingForConnections` and the next `start_sync` (or buffered `BlockHeaderSyncComplete` after reconnect) cleanly retries.

* fix(dash-spv): drop `ChainLockManager` sync events while waiting for connections

`MasternodeStateUpdated` is a one-shot trigger that flips `masternode_ready` and forces `Synced`. A buffered event consumed while the manager is in `WaitingForConnections` (between `stop_sync` and the next `start_sync`) would force a peerless `Synced`, contradicting the network state.

Gate `handle_sync_event` on `WaitingForConnections`. `on_disconnect` already resets `masternode_ready`, and `MasternodesManager` re-emits `MasternodeStateUpdated` from both `verify_and_complete` and `complete_incremental_pipeline` on the next sync cycle after reconnect, so dropping the buffered event loses nothing.

* fix(dash-spv): drop `InstantSendManager` sync events while waiting for connections

A buffered `MasternodeStateUpdated` consumed during `WaitingForConnections` would run `validate_pending` against state that `on_disconnect` already cleared. The existing `set_state(Synced)` was already gated on `Syncing | WaitForEvents` so state transitions were not affected, but the validation work itself is meaningless while peerless.

Gate `handle_sync_event` on `WaitingForConnections`. `MasternodesManager` re-emits `MasternodeStateUpdated` on the next sync cycle after reconnect.
* chore: add Manki AI code review configuration

* fix: pass `claude_code_oauth_token` to Manki action
* ci: upgrade manki to v5 and sync default workflow

Bump `manki-review/manki` to `@v5` and align the workflow with the latest default from upstream:

- Add `ready_for_review` to `pull_request` triggers
- Add `pull_request_review_comment: [created]` trigger for review-comment replies
- Add `dismissed` to `pull_request_review` types
- Add `actions: read` permission for run verification
- Move `concurrency` onto the `review` job and split `issue_comment` events by `comment.id` so distinct `/manki` commands no longer serialize against each other
- Guard the job with `if: github.actor != 'manki-review[bot]'` to prevent self-triggering
- Add `actions/setup-node@v4` with Node 24 (required by the v5 action)
- Drop `github_token` input since the Manki GitHub App handles PR access

The `claude_code_oauth_token` input is kept for now. It is deprecated upstream (Anthropic restricted Claude Code OAuth tokens for third-party tools on 2026-04-04) and emits a one-line warning per run, but it still works and avoids a secret migration. Switch to `anthropic_api_key` when convenient.

* ci: drop `cache: npm` from manki workflow

The upstream default sets `cache: npm` on `actions/setup-node`, but this repo has no `package-lock.json`/`yarn.lock` (Rust workspace), so the cache step errors with "Dependencies lock file is not found". Node is only needed to run the action itself, no cacheable install happens.

* ci: restore `github_token` input on manki action

Upstream SETUP says `github_token` is optional once the Manki GitHub App is installed, but on this repo the App is not installed, so the action errored with:

  No authentication configured. Provide either github_token or both github_app_id and github_app_private_key.

Pass `secrets.GITHUB_TOKEN` so the action can fall back to the workflow token for PR access.
* feat(dash-spv): add `SegmentCache::truncate_above` for reorg support

Drop segments entirely above the target and reset tail slots in the
boundary segment to the persistable sentinel. Dropped segment files are
queued for deletion on the next `persist`. This is the foundational
primitive for upcoming fork/reorg handling in storage.

* feat(dash-spv): add `truncate_above` to storage traits

Extend `BlockHeaderStorage`, `FilterHeaderStorage`, `FilterStorage`, and
`BlockStorage` with a `truncate_above(height)` method that drops all
items above the target. `PersistentBlockHeaderStorage` also prunes its
`header_hash_index` so orphaned hashes no longer resolve to a height.

All implementations route through `SegmentCache::truncate_above`, keep
changes in memory, and rely on the next `persist` to flush. `DiskStorageManager`
forwards each call to its inner storage. The internal `MockHeaderStorage`
used in the masternodes sync tests is updated to retain its index map.

* test(dash-spv): run `test_concurrent_truncate_and_read` on a multi-thread runtime

The default `#[tokio::test]` uses a single-threaded executor so the spawned
reader and writer tasks only interleave at `.await` points. Switch to a
two-worker multi-thread runtime so the test exercises genuine parallelism
between the truncate and the concurrent reads.

Addresses manki-review comment on PR #149
#149 (comment)

* test(dash-spv): cover persist + reopen durability of `header_hash_index` after truncation

The existing `test_truncate_above_drops_index_entries_and_allows_restore`
only verified in-memory pruning. Extend it with a `persist`, `drop`,
`open` cycle so the test exercises the full durability contract: rebuilt
indices from on-disk segments must not resurrect orphaned hashes.

Addresses manki-review comment on PR #149
#149 (comment)

* fix(dash-spv): retry failed segment file deletions on next `persist`

`SegmentCache::persist` previously drained `to_delete` unconditionally
before attempting `fs::remove_file`. A transient I/O failure dropped the
segment id from the retry queue, leaving the stale file on disk. On the
next `load_or_new` the directory scan would elect that file as the new
`max_seg_id` and rebuild `tip_height` from it, silently undoing a prior
`truncate_above`.

Collect failed deletions in a local set and re-insert them so the next
`persist` retries. Treat `NotFound` as success since the goal state is
already met.

Addresses manki-review comments on PR #149
#149 (comment)
#149 (comment)
#149 (comment)

* fix(dash-spv): keep `SegmentCache` truncation consistent across error and pre-`persist` re-stores

Two related defects in the truncation path:

`SegmentCache::truncate_above` removed segments from memory and queued them in `to_delete` before calling `get_segment_mut` for the boundary segment. A disk I/O error during that load returned early without updating `tip_height`, leaving `to_delete` populated and the cache reporting a tip whose backing data would be deleted by the next `persist`. Load the boundary segment first so any failure happens before mutating cache state.

`get_segment_mut` did not check `to_delete` before falling through to `Segment::load`. If a caller stored into a height belonging to a dropped segment after `truncate_above` but before `persist`, the stale on-disk file was loaded and `Segment::insert` then hit the `debug_assert!(self.items[offset] == I::sentinel())` panic. Return a fresh empty segment when the id is queued for deletion. `Segment::persist` uses `atomic_write` which replaces the file, so explicit pre-deletion is unnecessary.

Add `test_truncate_above_then_store_into_dropped_segment_without_persist` covering the exact reorg scenario (truncate, re-store into the dropped range, then persist + reopen).

Addresses manki-review comments on PR #149
#149 (comment)
#149 (comment)

* refactor(dash-spv): use `InvalidArgument` for `truncate_above` precondition

Distinguishes the bounds-check rejection from real write-path I/O failures so callers can tell a programming error apart from a transient disk problem.

Addresses Manki review comment on PR #149
#149 (comment)

* fix(dash-spv): preserve pending segment deletion across reads above truncated tip

`get_segment_mut` removes the entry from `to_delete` unconditionally and returns a fresh dirty sentinel segment, which is correct for the re-store path but cancels the deletion intent for any read. A `get_item` above the truncated tip would queue an all-sentinel rewrite instead of letting the next `persist` remove the stale file. Short-circuit such reads in `get_item` before reaching `get_segment_mut`.

Addresses Manki review comment on PR #149
#149 (comment)

* test(dash-spv): cover persist + reopen of within-segment `reset_above`

Truncate within a single segment, persist without re-storing, drop the cache, and reopen. Locks in the dirty-flag guard inside `reset_above` so a future refactor cannot silently leave stale sentinel slots on disk.

Addresses Manki review comment on PR #149
#149 (comment)

* test(dash-spv): rephrase concurrent storage test as contention test with `Barrier`

The outer `tokio::sync::RwLock` enforces mutual exclusion between the reader and writer tasks, so they cannot literally run in parallel regardless of runtime flavor. A `Barrier` guarantees both tasks reach the lock with overlapping intent, and the rename reflects what the assertions actually verify.

Addresses Manki review comment on PR #149
#149 (comment)

* refactor(dash-spv): use `height_to_segment_id`/`height_to_offset` in `truncate_above`

Addresses manki-review comment on PR #149
#149 (comment)

* fix(dash-spv): short-circuit `to_delete` segments in `get_items` range reads

Mirrors the guard already added to `get_item` so a range read that spans into a segment queued for deletion returns `InvalidArgument` rather than silently consuming the deletion intent. Without this guard the loop calls `get_segment_mut`, which removes the id from `to_delete` and hands back a fresh all-sentinel segment, swapping the stale on-disk file for an all-sentinel rewrite on the next `persist`.

Addresses manki-review comment on PR #149
#149 (comment)

* docs(dash-spv): clarify truncation semantics on storage range/single reads

Document that `SegmentCache::truncate_above` (and the four storage trait wrappers) is not durable until the next successful `persist`. A crash in between may leave orphaned segment files on disk and cause the cache to reopen at the pre-truncation tip.

Document that `get_items` (and the `load_headers`/`load_filter_headers`/`load_filters` trait methods) returns `StorageError::InvalidArgument` when the range spans a segment queued for deletion, and that callers must clamp the range to the tip or fall back to per-item reads.

Clarify on `get_item` that, by design, it returns `Ok(None)` for truncated slots where `get_items` would error, so it must not be used as a fallback for an error from the range API.

Addresses Manki review comments on PR #149.
#149 (comment)
#149 (comment)
#149 (comment)

* refactor(dash-spv): tighten storage truncation internals

Skip the `header_hash_index` retain pass on `PersistentBlockHeaderStorage::truncate_above` when the target is at or above the current tip. The inner `SegmentCache::truncate_above` is already a no-op in that case, so the O(n) walk over the index just to discard nothing was wasted work on tick-driven reorg checks.

Drop `pub` from `Segment::reset_above`. It is only invoked from `SegmentCache::truncate_above` within the same module and bypasses the `to_delete` bookkeeping that truncation maintains, so exposing it on the public surface only invites incorrect use.

Addresses Manki review comments on PR #149.
#149 (comment)
#149 (comment)

* test(dash-spv): cover sequential and persisted `truncate_above` scenarios

Add `test_sequential_truncations_within_same_segment` and `test_sequential_truncations_across_segment_boundaries` for `SegmentCache`, covering the reorg-after-reorg path where a second `truncate_above` lands at a lower height than the first. Both passes must leave the boundary segment as sentinels and keep `start_height` unchanged.

Add persist + reopen tests for `PersistentBlockStorage` and `PersistentFilterStorage` mirroring the existing `PersistentBlockHeaderStorage` durability test, so a regression in the boundary segment's dirty flag would surface uniformly across all three storage types.

Addresses Manki review comments on PR #149.
#149 (comment)
#149 (comment)

* test(dash-spv): cover `PersistentFilterHeaderStorage` truncate persist+reopen

Mirrors the `test_truncate_above_persist_reopen_*` coverage already present
for `PersistentBlockStorage` and `PersistentFilterStorage` so a regression in
the dirty-flag logic would be caught at this layer too.

Addresses manki-review on PR #149
#149 (comment)

* test(dash-spv): cover `DiskStorageManager` `truncate_above` block-header delegation

The composite manager is what callers actually use, so exercise the delegation
path that has non-trivial side effects (hash-index pruning). The other three
delegations are mechanically identical one-line forwards that don't warrant
dedicated tests on top of the per-storage coverage they already have.

Addresses manki-review on PR #149
#149 (comment)

* test(dash-spv): persist before `truncate_above` segment-boundary deletion check

Without a `persist()` call before `truncate_above`, segment 1's file is never written to disk, so the post-truncate `!exists()` assertion is trivially true regardless of whether the deletion logic works. Persist first, assert the file exists as a precondition, then verify it is removed after the truncate+persist cycle.

Addresses manki-review comment on PR #149
#149 (comment)

* fix(dash-spv): use `tokio::fs::remove_file` in async `SegmentCache::persist`

Replace blocking `std::fs::remove_file` with the async equivalent so the segment-deletion loop no longer stalls the Tokio executor while clearing files queued by `truncate_above`.

Addresses manki-review comment on PR #149
#149 (comment)

* test(dash-spv): assert kept items by value in `test_truncate_above_segment_boundary`

Compare the kept slice against `items[0..ITEMS_PER_SEGMENT]` instead of just its length so a zero-fill, reorder, or stale-segment regression in the boundary-segment branch of `truncate_above` would actually fail the test.

Addresses manki-review comment on PR #149
#149 (comment)
* feat(dash-spv): build dashd-style locator for getheaders

`BlockHeadersManager::build_locator` walks the storage tip backwards following the dashd algorithm (first 10 entries step 1, then double, always include genesis, capped at ~32 entries). `RequestSender::request_block_headers` and `request_block_headers_from_peer` now take a `Vec<BlockHash>` locator, `HeadersPipeline::send_pending` threads it to the tip segment, and the manager moves header storage ahead of follow-up `send_pending` so the locator is built from an up-to-date tip.

Lets peers on a fork find the most recent common ancestor without us having to guess at chain alignment, the prerequisite for Phase 2 staged fork detection.

* refactor(dash-spv): remove broken `ChainWork::from_height_and_header`

The helper ignored its `height` argument and returned the work of a single header, which is not cumulative work. Nothing called it. Phase 2 introduces a proper `ChainWork::accumulate` alongside its first caller so the API does not sit dead in the crate.

* refactor(dash-spv): strip unused `ChainTipManager` ahead of staged-fork rewrite

`ChainTipManager` was a generic multi-tip manager with no callers in the crate. Its eviction policy and active-tip selection do not match the new staged-fork design, which keeps the active chain in storage and buffers fork candidates per-peer. `ChainTip` itself stays, the upcoming `ForkBuffer` and its `ForkCandidate` carrier land alongside their first usage.

* feat(dash-spv): stage fork candidates with PoW, MTP, and DGW v3 validation

Adds the staged-fork detection pipeline that Phase 2 of #137 requires:

- New `chain::difficulty` module ports dashd's DGW v3 `GetNextWorkRequired` for retarget anchored at the fork ancestor's 24-block window. Built on a minimal in-crate 256-bit integer type because dashcore's public `Target` API does not expose the needed mul/div primitives.

- New `sync::block_headers::fork_buffer::ForkBuffer` keys branches by `(peer, fork-tip-hash)`, validates each ingested header against PoW target, BIP113 median-time-past, and DGW v3 expected bits, and exposes `take_winning_candidate` for promotion once a branch beats the active chain's extension work.

- `BlockHeadersManager::handle_headers_pipeline` now takes a `SocketAddr` and inspects incoming batches: a batch whose `prev_blockhash` resolves to a stored header at a height strictly less than tip is routed to the fork buffer instead of the normal pipeline. Catch-up batches (prev == tip) flow unchanged.

- `BlockHeadersManager::new` now takes a `Network` to instantiate the DGW params; `ChainWork::accumulate` returns cumulative work over a header slice (replaces the deleted broken `from_height_and_header`); `ForkCandidate` carries ancestor height, validated headers, and total branch work for the sync coordinator to consume.

- `tick` ages out fork branches at `FORK_BUFFER_TTL` (60s) and `PeerDisconnected` drops all branches sourced from the gone peer.

Reorg promotion is not yet wired into the sync coordinator. The tick handler logs pending candidates so detection can be confirmed end-to-end before that lands.

* chore: pr cleanup — import `Ordering`, remove caller/phase refs from comments

* fix(dash-spv): overlap header request with storage write to unblock sync

Move `send_pending` back ahead of `take_ready_to_store` so the next `getheaders` ships in parallel with the 8000-header disk write, restoring the pipelined behavior that initial sync relied on. `send_pending` now falls back to a single-entry locator anchored at the segment's `current_tip_hash` whenever the storage-derived locator's first entry does not match it, so mid-sync requests no longer wait for storage to catch up. Steady-state requests (storage == segment tip) still use the full multi-entry locator for fork detection.

This unsticks the slow integration sync that was timing out on Ubuntu/Windows CI runners, where 8000-header storage writes between batches were serializing the entire pipeline.

* chore(dash-spv): apply `cargo fmt` to staged-fork changes

Run `cargo fmt` on the files touched by the staged-fork series so the pre-commit hook stops rewriting them on CI.

* fix(dash-spv): validate ancestor height continuity in `ingest_fork`, cap per-peer `ForkBuffer` branches, route multi-batch fork continuations, handle testnet min-difficulty bits, warn on missing locator heights, add `debug_assert` for `U256::mul_u64` overflow

* fix(dash-spv): skip `build_locator` storage reads during active sync

During initial header sync, `handle_headers_pipeline` and `tick` called
`build_locator` on every batch (up to 25 sequential storage reads), but
`send_pending` always discarded the result because the segment tip advances
in memory ahead of storage, so the locator's first entry never matched the
segment's `current_tip_hash`. On slow CI machines the extra I/O caused the
peer reader to fall behind, stalling sync permanently at the second batch.

Pass `&[]` during active sync to let `send_pending` use the single-entry
fallback directly, eliminating unnecessary storage reads per batch.

* refactor: simplify `fork_tip_index` value to `u32` and prune on eviction

Drops the unused `SocketAddr` from `fork_tip_index` values — the stored peer
address was never read, only `ancestor_height`. Adds `prune_fork_tip_index`
(backed by a new `branch_tip_hashes` iterator on `ForkBuffer`) and calls it
after `remove_peer`, `expire_stale`, and `take_winning_candidate` so the index
cannot accumulate stale entries over a long-running session.

Addresses manki review comments on PR #152
#152 (comment)
#152 (comment)

* test: add coverage for `fork_tip_index` routing, branch cap, min-difficulty, and tick retry

- `fork_header_at_depth_is_routed_to_buffer`: extend with a second-batch assertion that
  confirms continuation routing via `fork_tip_index` (reaches `ingest_fork` validation,
  not silently dropped as a pipeline mismatch)
- `ingest_rejects_peer_exceeding_branch_cap`: verifies the 17th distinct branch from
  one peer is rejected with the expected error
- `ingest_accepts_valid_min_difficulty_fork_header`: exercises the `min_diff == Some(header.bits)`
  acceptance branch that was previously uncovered
- `tick_retries_sync_with_single_entry_locator_before_first_response`: confirms the tick
  handler issues a single-entry GetHeaders retry after a simulated timeout, not a full
  storage-derived locator

Addresses manki review comments on PR #152
#152 (comment)
#152 (comment)
#152 (comment)
#152 (comment)

* chore: apply cargo fmt

* fix(dash-spv): swallow `Validation` errors in fork continuation branch

Multi-batch fork continuation via `fork_tip_index` called `ingest_fork`
with `?`, propagating `SyncError::Validation("chain break")` to the
caller because the second batch anchors at the active-chain ancestor
rather than the buffered fork tip. Match on the result instead and
trace-log Validation errors so peers are not penalized.

Update `fork_header_at_depth_is_routed_to_buffer` to assert `Ok` from
the continuation call rather than pinning the broken error path.

* fix(dash-spv): narrow fork continuation swallow to `ForkChainBreak` only

Addresses manki-review warning on PR #152
#152 (comment)

* test(dash-spv): pin `ForkChainBreak` variant and use `MAX_FORK_HEADERS_PER_PEER` constant in tests

Add a focused unit test in `fork_buffer.rs` that constructs a chain-discontinuity scenario and asserts the error is specifically `SyncError::ForkChainBreak`. Expose `MAX_FORK_HEADERS_PER_PEER` as `pub(super)` and replace the hardcoded `4097` literal in `fork_continuation_non_fork_chain_break_error_propagates` with `MAX_FORK_HEADERS_PER_PEER + 1`.
…eam managers (#164)

* feat(dash-spv): add `ChainReorg` and `DeepReorgDetected` sync events

Add two new `SyncEvent` variants emitted by `BlockHeadersManager` when a fork candidate is promoted or denied. `ChainReorg` carries the fork ancestor height, old/new tip hashes, and the new generation counter value so downstream managers can cascade their truncation and discard stale in-flight responses. `DeepReorgDetected` reports a denied fork beyond the depth cap for monitoring.

Wire both events into the FFI dispatch and add corresponding `OnChainReorg` and `OnDeepReorgDetected` callback fields on `FFISyncEventCallbacks`. Update `ffi_cli` and existing test callback initializers to acknowledge the new fields. Header regeneration runs automatically via `build.rs`.

* feat(dash-spv): tag filter and block requests with `reorg_generation`

Add a shared `Arc<AtomicU64>` generation counter owned by `SyncCoordinator` and cloned into `BlockHeadersManager`, `FilterHeadersManager`, `FiltersManager`, and `BlocksManager`. Each manager snapshots the current generation when sending `GetCFHeaders`, `GetCFilters`, or `GetData` and records it on the corresponding `DownloadCoordinator` in-flight entry via the new `mark_sent_with_generation`.

On the receive path, each manager compares the per-request generation against its current snapshot before processing. A mismatch (the counter has advanced since the request was sent) drops the response with a `tracing::debug` line instead of corrupting the now-truncated chain state.

Generation tagging stays internal: no protocol-level change, no new message fields. Step 2 of [#140](#140). `handle_reorg` (Step 3) is what actually bumps the counter.

* feat(dash-spv): add `handle_reorg` with guards and cascade

Implement the cross-manager reorg pipeline that the staged-fork promotion hands off to. `handle_reorg` runs all guards before any storage mutation:

1. Single-flight gate via a `Mutex<ReorgState>`: concurrent calls return `Ok(None)` rather than racing.
2. Deny-list check on the candidate fork tip hash.
3. Checkpoint floor via `CheckpointManager::should_reject_fork`.
4. ChainLock floor (or a fresh-client fallback of `max(checkpoint_height, tip - 100)` when no chainlock is available).
5. Depth cap of 100 blocks: exceeded forks emit `DeepReorgDetected`.

Once every guard passes the function bumps `reorg_generation` and truncates the four storages (block headers, filter headers, filters, blocks) to the common ancestor, then persists the fork's headers. Returns `ChainReorg` carrying the new generation so downstream managers see the bump before any `ChainReorg`-driven reset runs.

Also adds `ChainLockManager::best_chainlock_height` (the floor source), `ForkCandidate::tip_hash` / `tip_height`, and `ReorgState::evict_expired_denials` so chainlock-floor rejections drop out once the local chainlock advances past them.

Six unit tests cover the cascade ordering, single-flight contention, the chainlock and checkpoint floors, deep-reorg denial, and deny-list TTL eviction.

* feat(dash-spv): wire `handle_reorg` into `BlockHeadersManager::tick`

Widen `BlockHeadersManager` to be generic over `FH`, `F`, `B` so the manager can hold direct handles for the four storages that the cascade truncates. Optional handles let filter-disabled clients skip filter/block storage entirely.

The tick handler now:
- evicts deny-list entries whose chainlock TTL has aged out,
- consumes any pending fork candidate from the staged-fork pipeline,
- drives `handle_reorg` with the manager's storage handles and the shared `reorg_generation` / `chainlock_height` snapshots,
- forwards the resulting `ChainReorg` or `DeepReorgDetected` event to the coordinator via the standard event return path.

`ChainLockManager` now publishes its best validated height onto a shared `Arc<AtomicU32>` so the cascade's chainlock floor reads a fresh value without holding a lock on the chainlock manager. The same handle is created once in `DashSpvClient::new` and threaded into both managers.

`drive_reorg` is the thin wrapper that bundles the manager's storage, generation, and chainlock snapshot into the `ReorgStorages` parameter `handle_reorg` expects.

* feat(dash-spv): cascade `ChainReorg` to filter/block managers

`FilterHeadersManager::handle_sync_event` now resets its pipeline and rewinds its progress to `fork_height` when a `ChainReorg` arrives, then returns to `WaitForEvents` so the next `BlockHeadersStored` event drives a fresh CFHeaders download from the truncated ancestor.

`FiltersManager` gains a `reset_for_reorg` helper that wraps `reset_for_rescan` plus the per-batch cursor rewind. `handle_sync_event` calls it on `ChainReorg` and returns to `WaitForEvents`.

`BlocksManager::handle_sync_event` drops the entire `BlocksPipeline` on `ChainReorg` and clears `filters_sync_complete` so the post-cascade refill can re-emit `FiltersSyncComplete` once the new chain re-stabilises.

* feat(dash-spv): flag chainlock-conflicting headers without banning peer

`BlockHeadersManager::ingest_fork` now checks the best chainlock height before routing a fork batch into the buffer. When the proposed ancestor is at or below the chainlock floor, the batch's headers are recorded in `side_headers` and the peer is left alone. The fork buffer never sees them, so they cannot mature into a candidate, but the record exists for monitoring and future debug tooling.

The peer is not banned: a buggy peer that gossips conflicting headers is still a useful source of valid headers on the canonical chain, and the chainlock floor already prevents any harm.

* test(dash-spv): cover reorg cascade, depth cap, and stale generation

Add two unit tests in addition to the six `reorg.rs` ones already in place:

- `shallow_reorg_cascade_truncates_storage_and_emits_chain_reorg` drives a real `ingest_fork` -> `drive_reorg` flow against a regtest chain and asserts that storage is truncated, the new tip matches the fork tip, the `ChainReorg` event carries the new generation, and the counter is bumped.
- `test_generation_tagging_round_trips_through_pipeline` confirms the `FiltersPipeline` round-trips the generation snapshot through `mark_sent_with_generation` and `generation_for_height` and that unsolicited responses (no in-flight batch) return `None` instead of being mistakenly treated as stale.

The depth cap, single-flight contention, checkpoint floor, and chainlock floor are already covered by the `sync::reorg::tests` module added in the `handle_reorg` commit. Wallet-rewind acceptance is deferred to #145 per the design decision in the issue.

* chore: clean up unused accessors and apply fmt

Drop dead accessors (`SyncCoordinator::reorg_generation`, `ChainLockManager::best_chainlock_height`, `ForkCandidate::tip_height`, and the `send_pending` wrappers on pipelines that always tagged generation `0`) so `cargo clippy --all-features --all-targets -- -D warnings` is clean. The cascade reads the shared `Arc<AtomicU32>` chainlock height directly from `BlockHeadersManager` rather than going through the chainlock manager, so the wrapper is genuinely unused.

* chore: move inline imports to `mod tests` top, drop uppercase emphasis

* fix: correct chainlock floor guard from \`<=\` to \`<\` in \`handle_reorg\`

A fork whose ancestor height equals the chainlocked height diverges at
\`cl_height+1\` and does not rewrite any finalised block, so the
\`ancestor_height <= cl_height\` guard was incorrectly rejecting valid fork
candidates. Change to \`ancestor_height < cl_height\` in both
\`run_guards_and_cascade\` and the matching guard in \`ingest_fork\`.

Addresses manki-review comment on PR #164
#164 (comment)

* fix: correct \`ingest_fork\` chainlock floor, cap \`side_headers\`, extend tests

Three related fixes in \`BlockHeadersManager\`:
- Correct the \`<=\` chainlock floor to \`<\` in \`ingest_fork\` to match
  \`handle_reorg\` and the DIP-0008 finality semantics.
- Add \`MAX_SIDE_HEADERS = 10_000\` cap with oldest-entry eviction to bound
  \`side_headers\` memory growth under sustained peer churn.
- Assert storage truncation in \`shallow_reorg_cascade_truncates_storage_and_emits_chain_reorg\`
  (old-chain headers absent, fork headers present at correct heights).
- Add \`drive_reorg_returns_none_when_guard_rejects_candidate\` covering the
  guard-rejection \`None\` path through \`BlockHeadersManager\`.

Addresses manki-review comments on PR #164
#164 (comment)
#164 (comment)
#164 (comment)
#164 (comment)

* test: add \`ChainReorg\` pipeline-reset coverage to \`BlocksManager\`

Constructs a manager with in-flight pipeline work, fires
\`SyncEvent::ChainReorg\` via \`handle_sync_event\`, then asserts
\`pipeline.is_complete()\`, \`filters_sync_complete == false\`, and
\`state == WaitForEvents\`.

Addresses manki-review comment on PR #164
#164 (comment)

* test: wire FFI reorg callbacks in \`CallbackTracker\` test harness

Add \`chain_reorg_count\` and \`deep_reorg_count\` atomics to
\`CallbackTracker\` and implement real \`on_chain_reorg\` /
\`on_deep_reorg_detected\` FFI callbacks that increment them. Replace
the \`None\` placeholders in \`create_sync_callbacks\` so any integration
test that triggers a reorg will observe non-zero counts rather than
silently swallowing the event.

Addresses manki-review comment on PR #164
#164 (comment)

* fix: clamp \`side_headers\` drain to buffer length

Addresses manki-review review comment on PR #164
#164 (comment)

* refactor: extract \`is_below_chainlock_floor\` predicate to deduplicate chainlock guard

Addresses manki-review review comment on PR #164
#164 (comment)

* test: assert \`ancestor_height == cl_height\` passes chainlock floor guard in \`drive_reorg\`

Addresses manki-review review comment on PR #164
#164 (comment)
* feat(dash): add `MasternodeListEngine::truncate_above` for reorg rewind

Adds a primitive that drops every piece of cached state whose anchor sits above a target height: `masternode_lists`, hash-keyed `known_snapshots` and `rotated_quorums_per_cycle` (via `block_container` lookup), and `quorum_statuses` height sets. Orphaned hashes are dropped conservatively. The container is trimmed last so hash lookups remain valid during cleanup. Idempotent under repeated invocation.

* feat(dash-spv): add `MasternodesManager::rewind_to_height`

Truncates the shared masternode engine, prunes the manager's height-tracked sync state, refreshes `last_synced_block_hash` from the surviving engine tip, clears straggler/dedup state, transitions to `Syncing`, and dispatches a fresh QRInfo for the new tip.

* feat(dash-spv): trigger masternode rewind on `ChainReorg`

`MasternodesManager::handle_sync_event` now intercepts `SyncEvent::ChainReorg` before any other arm and delegates to `rewind_to_height`, mirroring the cascade pattern already in `FilterHeadersManager` and `BlocksManager`.

* feat(dash-spv): hard-block chainlock validation across reorg

`ChainLockManager` listens for `SyncEvent::ChainReorg` and flips `masternode_ready` back to `false` while dropping any cached `pending_validation`. Subsequent chainlocks are cached until the next `MasternodeStateUpdated` flips readiness back on, which mirrors the pre-startup path.

* feat(dash-spv): drop pending instantlocks across reorg

`InstantSendManager` listens for `SyncEvent::ChainReorg` and clears `pending_instantlocks`. Pre-reorg IS locks were validated against a quorum view that no longer matches the new chain, so retrying them would either succeed under the wrong cycle or fail spuriously. Future IS locks re-received from the network are picked up via the existing `MasternodeStateUpdated` retry path. Also drops the dead `MasternodesManager::is_synced_for_height` query and its test.

* test(dash-spv): cover masternode-list rewind across DIP-3 reorg

Adds a regtest integration test that orchestrates a reorg via the existing controller node (`invalidateblock` + replacement chain), observes the SPV's `ChainReorg` event, verifies the engine truncates above the fork before refilling, and confirms a post-rewind `MasternodeStateUpdated` lands above the fork. Also adds `MasternodeTestContext::mine_reorg` and a `wait_for_chain_reorg_event` helper.

* test(dash-spv): cover qrinfo refresh across DIP-24 cycle reorg

Adds an integration test for the full DIP-24 rotation-cycle reorg path: mine a DKG cycle, capture its commitment hash, invalidate enough to roll back the commitment block, mine a replacement DKG cycle, and verify the engine drops the orphaned cycle from `rotated_quorums_per_cycle` and refills it under the replacement key.

The replacement DKG is flaky on the existing regtest masternode harness so the test is gated behind `#[ignore]` with a tracking note tied to #142. The body still compiles to catch refactors that break the helper surface.

* chore(dash-spv): apply rustfmt to reorg integration test scaffolding

* chore: pr cleanup — move inline imports to `mod tests` top, remove what-comments, import `Range`

* test(dash-spv): wait for full filter sync before triggering reorg in `test_masternode_list_rewind_across_dip3_reorg`

On macOS ARM, the filter pipeline had not yet downloaded filters for the
3 most recent blocks when `mine_reorg` was called. After the reorg those
block hashes were orphaned, so dashd disconnected every reconnect attempt
that included a `GetCFilters` for an orphaned stop hash, preventing the
`GetHeaders2` fork-detection request from ever getting a response and
causing the 60 s `wait_for_chain_reorg_event` timeout.

Ubuntu/Windows passed because the slower VMs gave the filter pipeline
enough time to finish before the reorg was triggered.

Fix: add `wait_for_full_sync` (waits for `SyncProgress::is_synced()`)
and call it after `wait_for_mn_state_event_above` and before
`mine_reorg`, ensuring no orphan-hash filter requests remain in flight.

* fix: merge engine lock acquisitions and add error recovery in `rewind_to_height`

Addresses manki-review comments on PR #165
#165 (comment)
#165 (comment)
#165 (comment)

* refactor: encapsulate `ChainLockManager` reorg state via `reset_for_reorg()`

Addresses manki-review comment on PR #165
#165 (comment)

* test: add optional fork-height filter to `wait_for_chain_reorg_event`

Addresses manki-review comment on PR #165
#165 (comment)

* fix(`ChainLockManager`): preserve `pending_validation` across peer disconnect

Adds `reset_for_disconnect()` that clears only `masternode_ready`, and
switches `on_disconnect` to use it instead of `reset_for_reorg()`.
A chainlock cached before masternode sync completes remains valid on the
same chain and must survive disconnect so `on_masternode_ready` can
re-validate it on the next reconnect.

Addresses manki-review comment on PR #165
#165 (comment)

* test(`MasternodesManager`): cover `rewind_to_height` send failure path

Adds `test_rewind_to_height_sets_wait_for_events_on_send_failure` to
assert that when `send_qrinfo_for_tip` errors (dropped channel), the
manager transitions to `WaitForEvents` and clears `qrinfo_in_flight`,
leaving it recoverable by the next `BlockHeaderSyncComplete` event.

Addresses manki-review comment on PR #165
#165 (comment)

* feat(`dash-spv`): extend buffered fork branches across multi-message reorg announcements

dashd announces reorgs as N separate `headers2` messages (one header each, because each `generatetoaddress` block produces a `headers` push). The first batch enters the fork buffer via `ingest_fork`. Subsequent batches whose `prev_blockhash` is the previous fork tip re-entered `ingest_fork`, which then failed chain-continuity against the *active-chain* ancestor and was swallowed with a "deferred to Phase 3" debug log. The branch was stuck at one header, never outweighed the active extension, and `ChainReorg` never fired.

Add `ForkBuffer::extend_branch` that looks up the existing branch by `(peer, prev_tip_hash)`, validates each continuation header (continuity off the buffered branch tip, PoW, MTP, DGW v3 against a rolling window of `history + buffered + new`), appends them, and re-keys the branch under the new tip. The caller receives a `BranchUpdate` with the new tip/height/work so it can refresh `fork_tip_index` and re-evaluate promotion. The per-header validation loop is factored out of `ingest` into `validate_chain` so both paths share identical rules.

`BlockHeadersManager` routes `fork_tip_index` hits through a new `extend_fork` helper that loads the same active-chain history as `ingest_fork`, calls `extend_branch`, refreshes the index, and runs `take_winning_candidate` so a branch that just outweighs the active chain promotes immediately.

Verified locally with `test_masternode_list_rewind_across_dip3_reorg` (previously timed out, now completes in ~2s).

Refs [#142](#142), [#165](#165).

* fix(`ChainLockManager`): document phase-ordering invariant in `reset_for_disconnect`, add behavioral tests

Addresses manki-review comments on PR #165:
- #165 (comment)
- #165 (comment)
- #165 (comment)

* test(`ChainLockManager`,`MasternodesManager`): strengthen round-4 manki assertions

- Assert return value and `progress.invalid()` in `test_pending_validation_survives_disconnect_and_consumed_on_reconnect` to cover the re-broadcast contract of `on_masternode_ready`
- Add `engine.masternode_lists.is_empty()` boundary assertion in `test_rewind_to_height_sets_wait_for_events_on_send_failure` to verify surviving entries, not just absent ones
- Add `test_reorg_prevents_stale_pending_validation_from_being_revalidated` as a companion test for the phase-ordering invariant documented on `reset_for_disconnect`

* test(`ChainLockManager`,`MasternodesManager`): address round-5 manki assertions

Assert `masternode_ready == true` after `on_masternode_ready()` in the reorg
test (the flag flips unconditionally, even when `pending_validation` is None).

Seed a surviving entry at height 30 in `test_rewind_to_height_sets_wait_for_events_on_send_failure`
and assert `contains_key(&30)` after truncation at fork_height=50, closing the
absence-only gap manki flagged.

The disconnect-test `progress.invalid()` assertion (thread 3) was already applied
in round 4.

Addresses manki-review comments on PR #165
#165 (comment)
#165 (comment)
#165 (comment)
…chain (#166)

* feat(dash-spv): add `force` flag to `handle_reorg` to bypass depth cap

`handle_reorg` (and `run_guards_and_cascade`) now take a `force: bool` parameter. When `force` is `true` the depth cap is skipped (a validated `ChainLockForcedReorg` overrides it). Checkpoint floor, chainlock floor, single-flight, and deny-list guards still apply.

Updates the single in-tree caller in `BlockHeadersManager::drive_reorg` to pass `force: false` and exposes a `force` parameter so an upcoming forced-reorg path can override the cap.

* feat(dash-spv): add `PendingChainLockQueued` and `ChainLockForcedReorg` `SyncEvent` variants

Adds two purely additive `SyncEvent` variants used by the upcoming
`EnforceBestChainLock` path:

- `PendingChainLockQueued` signals that a CLSig arrived for a block hash
  the local header chain has not resolved yet. The chainlock is held in
  a pending queue and re-evaluated when the matching header arrives.
- `ChainLockForcedReorg` signals that a validated CLSig disagrees with
  the local block at its claimed height and the chain must reorg onto
  the chainlocked branch.

Other managers fall through their existing `_` arms, so no consumer
changes are required.

* feat(dash-spv): pending-CLSIG queue with TTL in `ChainLockManager`

Replaces the previous boolean `verify_block_hash` with a 3-way [`BlockHashVerification`] enum (`Match`, `Mismatch { local_header_hash }`, `Unknown`). `process_chainlock` now routes each branch explicitly:

- `Match`: existing validated path (unchanged).
- `Unknown`: queue the CLSig in a new `pending_unknown_hash` map (5min TTL) and emit `PendingChainLockQueued`. The queued entry preserves the delivering peer for later misbehavior reporting if the hash later resolves to a `Mismatch`.
- `Mismatch`: rejected for now; the forced-reorg dispatch lands in a follow-up commit.

The existing `pending_validation` cache (masternode-not-ready) is unchanged and runs ahead of the unknown-hash queue so a pre-ready CLSig still gets retried on the not-ready → ready transition.

`tick` calls `drain_expired_pending` to evict stale entries.

* feat(dash-spv): retry pending CLSIG queue on `BlockHeadersStored`

`ChainLockManager::retry_pending_unknown_hash` re-evaluates every queued CLSig against the current header chain. `Match` entries are removed and fed back through `process_chainlock` so the signature path runs and the chainlock is promoted on success. `Mismatch` entries are removed and rejected for now. The forced-reorg dispatch on `Mismatch` lands in a follow-up commit.

The chainlock sync manager invokes the retry on every `BlockHeadersStored` sync event so newly-stored headers immediately unstick CLSigs that were waiting on them.

* feat(dash-spv): `Mismatch` triggers forced reorg via `ChainLockForcedReorg`

Wires the full DIP-0008 EnforceBestChainLock path:

1. `MisbehaviorKind::InvalidChainLockSignature` and a new `NetworkRequest::ReportMisbehavior(SocketAddr, MisbehaviorKind)` variant let any sync manager apply a reputation penalty without holding a direct handle to the `PeerReputationManager`. The network manager handles the request by calling `update_reputation(.., misbehavior_scores::INVALID_CHAINLOCK, ..)`. `RequestSender::report_misbehavior` is the new public API.

2. `ChainLockManager::process_chainlock` now takes a `requests: &RequestSender`. On a `Mismatch` branch the signature is validated first: an invalid signature reports the delivering peer for `InvalidChainLockSignature`. A valid one emits `ChainLockForcedReorg { chain_lock, fork_height }`. The retry path over the pending-unknown-hash queue runs the same logic.

3. `BlockHeadersManager` handles `ChainLockForcedReorg`: it stores the chainlock in `cl_forced_reorg_pending` and broadcasts a getheaders so peers on the chainlocked branch deliver the missing headers. When incoming headers ingest into the fork buffer, `try_drive_forced_reorg` pulls the branch by tip hash (no work check) and calls `drive_reorg(.., force: true)` to bypass the depth cap. The chainlock-floor side-list is also bypassed for the forced-reorg target so the cascade can succeed.

The `PendingChainLockQueued` and `ChainLockForcedReorg` variants are not surfaced to the FFI callback layer for now. Consumers receive the resulting `ChainReorg` once the cascade completes.

* test(dash-spv): integration test for `EnforceBestChainLock` against dashd

Adds a `dashd_masternode` integration test that walks the full DIP-0008 forced-reorg path: sync against the regtest masternode network, wait for a chainlock, drive `mine_reorg(4)` so the chainlocked block is orphaned, wait for a new chainlock on the replacement chain, and assert `ChainReorg` fires for the orchestrated `fork_height`.

Marked `#[ignore]` because regtest CLSig re-delivery onto a peer pinned to a different branch is timing-sensitive. The controller and masternodes share one chain state, so once `mine_reorg` succeeds the masternodes pivot too and the SPV gets the new branch through normal headers sync, not through the forced-reorg path. Reproducing a true CL-vs-local disagreement in regtest requires peer-network isolation the current harness does not provide, tracked under [#141](#141).

* chore: fix prose separator violations in new comments

* fix(dash-spv): gate chainlock floor behind `force` in `run_guards_and_cascade`

A BLS-validated CLSig driving a forced reorg has higher authority than
the locally cached chainlock height. Wrapping the chainlock floor check
in `if !force` mirrors the existing depth-cap gate and allows recovery
when the common ancestor lies below the stored floor.

Addresses manki-review comment on PR #166
#166 (comment)

* fix(dash-spv): use `entry().or_insert_with()` in `pending_unknown_hash` insert

Last-wins overwrite allowed a peer sending a CLSig with the correct hash
but invalid BLS signature to displace the honest peer's entry, causing
the honest CLSig to be silently discarded. Keeping the first insertion
prevents this without affecting TTL eviction semantics.

Addresses manki-review comment on PR #166
#166 (comment)

* fix(dash-spv): emit `ChainLockForcedReorg` from `on_masternode_ready` on `Mismatch`

A CLSig arriving during initial sync that points to a competing block was
silently dropped in `on_masternode_ready`. This inconsistency with the
`process_chainlock` Mismatch path meant the forced-reorg window was missed
for that timing window. The method now returns `Vec<SyncEvent>` and mirrors
the Mismatch arm from `process_chainlock`.

Addresses manki-review comment on PR #166
#166 (comment)

* docs(dash-spv): update stale doc comment for `retry_pending_unknown_hash` `Mismatch` arm

The comment said forced-reorg dispatch was in a follow-up commit, but the
implementation already re-routes through `process_chainlock` which handles
Mismatch. Also renames and extends the corresponding unit test to assert the
correct behavior (invalid-sig mismatch drops the entry and counts invalid).

Addresses manki-review comment on PR #166
#166 (comment)

* fix(dash-spv): clear `cl_forced_reorg_pending` on disconnect and on guard block

On disconnect the pending forced-reorg tip must be cleared so it cannot
drive a reorg against a different branch sharing the same tip hash on
reconnect. On guard block (deny-list or checkpoint rejection), the field
is now cleared unconditionally once a candidate has been consumed from
the fork buffer, since retrying the same candidate will always fail.

Addresses manki-review comment on PR #166
#166 (comment)

* test(dash-spv): add direct unit tests for `take_branch_by_tip` in `ForkBuffer`

Covers: (a) existing tip found removes the branch and returns the correct
`ForkCandidate`, (b) unknown tip returns `None` and leaves the buffer
unchanged, (c) two branches with different tips only the targeted branch
is removed. Matches the coverage style of other `ForkBuffer` methods.

Addresses manki-review comment on PR #166
#166 (comment)

* chore: apply  formatting

* fix(dash-spv): advance `best_chainlock` floor in `on_masternode_ready` Mismatch arm

Addresses manki-review comment on PR #166
#166 (comment)

* test(dash-spv): add missing unit tests from round-2 manki review

-  Mismatch + invalid-sig arm side-effects
- chainlock-floor bypass exercised with `force=true` in `handle_reorg`
- first-wins behavior of `pending_unknown_hash` after `or_insert_with` fix
- `take_branch_by_tip` removes exactly one entry when two peers share the same tip hash

Addresses manki-review comments on PR #166
#166 (comment)
#166 (comment)
#166 (comment)
#166 (comment)

* refactor(dash-spv): extract `commit_validated_chainlock` and defer floor advance on `Mismatch`

The `Mismatch` arm of `on_masternode_ready` was advancing `best_chainlock`,
`chainlock_height`, and the disk state before the forced-reorg cascade ran.
If the cascade never completes the persisted chainlock floor would point to a
block not on the active chain, exactly the inconsistency this PR prevents.

Aligns with `process_chainlock`'s `Mismatch` arm which emits
`ChainLockForcedReorg` without advancing the floor. The floor advances only
on `Match`/`Unknown` paths (active chain agrees) via the extracted
`commit_validated_chainlock` helper, eliminating the duplication that would
otherwise allow the two paths to drift.

Addresses manki-review comments on PR #166
#166 (comment)
#166 (comment)
…nel (#167)

* feat(storage): add reorg-in-progress sentinel and `delete_metadata` to `MetadataStorage`

Add four new methods on the `MetadataStorage` trait: `delete_metadata`,
`write_reorg_sentinel`, `clear_reorg_sentinel`, and `is_reorg_sentinel_set`.
The sentinel is a zero-byte file at `metadata/reorg_in_progress.dat` written
via `atomic_write` and removed via `tokio::fs::remove_file`. Implementations
are added to `PersistentMetadataStorage` and delegated through
`DiskStorageManager`.

These primitives back the upcoming startup consistency check: the cascade
will write the sentinel before truncation and clear it after, so a crash
mid-cascade is detectable on the next startup.

* feat(dash-spv): write reorg sentinel before cascade and clear after success

Thread a `metadata_storage` handle into `ReorgStorages` so `handle_reorg`
can mark the cascade in progress immediately before generation bump and
clear it once the last `truncate_above` returns. A crash between the
write and the clear leaves the sentinel on disk, which the upcoming
startup consistency check will use to recompute a safe tip.

Add a unit test that drives a successful cascade and confirms the
sentinel is set during the cascade and cleared on completion.

* feat(storage): add `check_and_repair_consistency` for cross-storage invariants

Add a standalone async function that enforces four invariants between the
block-header tip and the downstream storages:

- `filter_header_tip <= block_header_tip`
- `filter_tip <= filter_header_tip`
- `block_storage_tip <= block_header_tip`
- BIP157 filter header range readable end-to-end

For each violation, the offending storage is truncated to the safe tip and
persisted immediately. The repair is idempotent. Wired into
`DiskStorageManager::new` so it runs unconditionally on every open, before
any sync task observes the storages.

* feat(storage): compute valid tip via `header_hash_index` when sentinel is present

Add `highest_valid_tip` on `PersistentBlockHeaderStorage`. It walks backward from the current tip, returning the first height whose `prev_blockhash` resolves to the immediately preceding height in `header_hash_index`. Used by `check_and_repair_consistency` when the reorg-in-progress sentinel is found on startup: every storage is truncated to the recovered safe tip and the sentinel is cleared before the regular invariant checks run.

* feat(storage): truncate cache to last readable segment when a segment file is corrupt

`SegmentCache::load_or_new` previously errored on the first unreadable segment file, refusing to open the cache. Walk segment ids from highest to lowest instead, logging a warning and skipping any segment whose `Segment::load` fails. The first readable id becomes the tip; the lowest readable id at or below it becomes the start. This keeps the SPV client startable when an isolated segment file is corrupt, and gives the startup consistency check a stable base to truncate downstream storages against.

Add a unit test using a non-minimal VarInt prefix to force a non-EOF decode error in segment 1, verifying the cache reopens with the tip pinned at segment 0.

* feat(storage): validate and clear stale chainlock on startup

After the block-header tip is confirmed, load the persisted chainlock blob via `MetadataStorage::load_metadata` and decode it. If the chainlock's `block_height` is strictly above the block tip, or the blob fails to deserialize, clear it via `delete_metadata` and emit a warning. This prevents the chainlock manager from trusting a value the local header chain can no longer corroborate after a mid-cascade crash repair.

* docs(storage): document why the consistency check precedes the background worker

The placement before `start_worker` is load-bearing: a stale tip persisted by the worker's first tick would clobber the sentinel-driven recovery. Add a comment so a future reader doesn't reorder the two calls.

* feat(key-wallet): add `WalletInterface::clamp_heights_to` and call from client startup

Add an async `clamp_heights_to(tip)` method on the `WalletInterface` trait with a default no-op so existing implementors compile unchanged. The `WalletManager` implementation iterates internal wallets and clamps both `last_processed_height` and `synced_height` to `min(current, tip)`.

Call it from `DashSpvClient::new` after the storage layer's startup consistency check has settled the block-header tip, so a sentinel-driven repair cannot leave wallet metadata pointing above a height the local chain no longer contains.

* test(storage): unit tests for sentinel and consistency repair

Cover the new startup-repair surface: `highest_valid_tip` returns the full tip when the chain is intact and the start sentinel when broken, each invariant violation (filter header > block tip, filter > filter header tip, block above block tip) triggers the matching downstream truncation, a stale chainlock above the header tip is cleared, the sentinel branch truncates to the safe tip and clears the marker, and `WalletManager::clamp_heights_to` lowers both metadata fields when above the tip and is a no-op otherwise.

* chore: apply `cargo fmt` to startup consistency check files

* chore: pr cleanup — move inline imports to module top, fix qualified paths, fix prose separator

- Move `use dashcore::ephemerealdata::chain_lock::ChainLock` from inside
  `chainlock_forced_reorg_drives_cascade_for_lighter_branch` test fn to
  `mod tests` top in `block_headers/manager.rs`; `BLSSignature` was already
  present there.
- Add `use dashcore::{BlockHash, bls_sig_utils::BLSSignature}` and
  `use std::path::{Path, PathBuf}` to `mod tests` in `consistency.rs`;
  replace qualified `dashcore::BlockHash::all_zeros()`,
  `dashcore::bls_sig_utils::BLSSignature::from(...)`, `std::path::Path`,
  and `std::path::PathBuf` occurrences with the short forms.
- Add `use crate::network::NetworkRequest` to `mod tests` in
  `chainlock/manager.rs`; replace `crate::network::NetworkRequest::...`
  qualified match arm with `NetworkRequest::...`.
- Add `use std::slice` to `mod tests` in `fork_buffer.rs`; replace
  `std::slice::from_ref(...)` with `slice::from_ref(...)`.
- Replace `std::sync::atomic::Ordering::SeqCst` (fully qualified, redundant
  via `use super::*`) with `Ordering::SeqCst` in new `reorg.rs` tests.
- Fix prose separator `—` to `.` in `chainlock/manager.rs` comment.

* fix(storage): address manki review findings for startup consistency PR

- Replace `unwrap_or(0)` with `if let Some` in filter invariant check so
  absent filter headers do not trigger an invalid truncation below
  `start_height`
- Correct `highest_valid_tip` docstring to accurately describe the
  single-link walk-back behavior instead of claiming full chain verification
- Fix `highest_valid_tip` signature from `&self` to `&mut self` to match
  the write lock it acquires on the inner `SegmentCache`
- Make `is_reorg_sentinel_set` async and use `tokio::fs::try_exists` to
  avoid blocking the Tokio executor with a synchronous `Path::exists` call
- Deduplicate sentinel path construction in `DiskStorageManager` by
  adding `PersistentMetadataStorage::sentinel_path_for` and delegating
- Downgrade sentinel clear failure in the reorg cascade from a propagated
  error to a logged error so a failed `clear_reorg_sentinel` after a
  successful cascade does not make the reorg appear to have failed
- Queue corrupt segments for deletion in `load_or_new` so they are
  removed on the next `persist` instead of producing repeated warnings
  on every restart
- Add compile-time assertion that `PROBE_WINDOW > MAX_REORG_DEPTH`
- Extend sentinel test to populate downstream storages and assert they
  are truncated to the safe tip
- Add `highest_valid_tip` test for a partially broken chain (valid
  through height N-1, disconnected at N)
- Add PROBE_WINDOW boundary tests documenting the probe-window limitation

* test(storage): cover 'filters but no filter headers' path and block-storage sentinel truncation

Addresses manki-review comments on PR #167
#167 (comment)
#167 (comment)

* fix(storage): clear orphaned filters when filter headers are absent on startup

Adds `SegmentCache::clear_all` and `FilterStorage::clear_all` so the consistency
check can fully reset filter storage when filter headers are absent but filter
segments exist. Without this the filter sync used `filter_tip_height` to skip
re-downloading segments that could belong to a discarded chain.

Addresses manki-review comment on PR #167
#167 (comment)

* fix(storage): include all on-disk segment IDs in `SegmentCache::clear_all`

Addresses manki-review comment on PR #167
#167 (comment)

* test(storage): verify `clear_all` durability survives reopen in consistency test

Addresses manki-review comment on PR #167
#167 (comment)

* fix(dash-spv): drop block-header read guard before acquiring wallet lock

Bind `get_tip_height` into a local so the `RwLockReadGuard` is dropped before the wallet write lock is acquired, preventing deadlock for future `WalletInterface` impls that touch block-header storage.

Addresses manki review comment on PR #167
#167 (comment)

* test(storage): cover sentinel-cleared path when block headers are empty

Ensures the `None` arm of the sentinel recovery match in `check_and_repair_consistency` clears the sentinel, so a subsequent startup with empty block-header storage does not loop into recovery.

Addresses manki review comment on PR #167
#167 (comment)

* docs(key-wallet-manager): document `clamp_heights_to` as intentionally infallible

Makes the `()` return contract explicit so future `WalletInterface` implementors do not add silent error paths from inside the SPV client's startup clamp.

Addresses manki review comment on PR #167
#167 (comment)
…ariants (#168)

* feat(key-wallet): add `Conflicted` and `Abandoned` variants to `TransactionContext`

Introduces two new contexts the wallet will need once reorg/rewind handling lands.

`Conflicted` carries the previous context boxed so UIs can render what state the
tx was in before the conflict. `Abandoned` is terminal.

Helper methods (`confirmed`, `is_instant_send`, `is_chain_locked`, `block_info`)
return false / None for both new variants and `Display` gets the matching arms.

Also extends the FFI mirror in `key-wallet-ffi` with new no-op `Conflicted` /
`Abandoned` discriminants so callers can read the type. Constructing these
variants over the FFI boundary is not supported (the boxed previous context is
not surfaced).

No behavior change yet, mechanical match-extension only.

* feat(key-wallet): release UTXOs and `spent_outpoints` for inactive transactions

When a transaction transitions to `Conflicted` or `Abandoned`, its outputs
disappear from the spendable set and the outpoints it had marked as spent are
released from `spent_outpoints`. Restoring the original inputs to the UTXO set
is the rewind logic's job (see #145), not this method.

Routed through a small `release_inactive_utxos` helper invoked at the top of
`update_utxos` whenever `context.is_inactive()` holds. `TransactionContext`
gains the matching `is_inactive` predicate.

* test(key-wallet): cover `Conflicted` and `Abandoned` `TransactionContext` variants

Unit tests inline in `transaction_context.rs` verify the new variants' helper
methods (`confirmed`, `is_instant_send`, `is_chain_locked`, `block_info`,
`Display`, `is_inactive`) and that `Conflicted.previous` round-trips.

Integration tests in `tests/conflicted_abandoned_tests.rs` drive the funds
account through realistic lifecycles:

- A fresh account receiving a `Conflicted` tx keeps an empty balance and no
  UTXOs are recorded.
- Promoting an existing `InBlock` record to `Conflicted` drops the UTXO from
  the spendable set and preserves the previous block context inside the
  variant.
- Promoting an existing `InBlock` record to `Abandoned` drops the UTXO and
  leaves the record in the terminal state.

* chore: pr cleanup — trim caller-reference doc comments

Remove "Invoked when..." framing from `release_inactive_utxos` (caller
reference belongs in a PR description, not a doc comment) and drop the
issue-number reference from `transition_to_inactive` in the test helper.

* fix(key-wallet): exclude inactive transactions when rebuilding `spent_outpoints`

The custom `Deserialize` impl rebuilt `spent_outpoints` from every recorded transaction's inputs, including `Conflicted` and `Abandoned` records. Since `release_inactive_utxos` removes those inputs from `spent_outpoints` when a transition to inactive occurs, the post-deserialization set ended up re-marking released outpoints as spent, blocking re-spend of the same coins.

Addresses manki-review comment on PR #168
#168 (comment)

* fix(key-wallet): signal `confirm_transaction` confirmed-to-inactive transitions

The `changed = !was_confirmed` expression suppressed transitions from a confirmed state (e.g. `InBlock`) to `Conflicted` or `Abandoned`, returning `None` even though the change releases UTXOs and zeroes balance. Include `is_inactive()` in the signal so callers can refresh balances on the transition.

Addresses manki-review comment on PR #168
#168 (comment)

* docs(key-wallet): clarify deferred rewind in `release_inactive_utxos`

Make the deferred predecessor-UTXO restore path explicit and link it to issue #145 so the limitation is visible to future readers and downstream wallets.

Addresses manki-review comment on PR #168
#168 (comment)

* test(key-wallet): cover spend-tx going inactive releases input outpoints

Add a test that funds the wallet, confirms a spending transaction, then drives the spend to `Abandoned` and verifies the released input outpoint is re-trackable when the funding transaction is re-processed. Exercises the `spent_outpoints.remove` branch in `release_inactive_utxos`, previously dead in the test suite.

Addresses manki-review comment on PR #168
#168 (comment)

* test(key-wallet-ffi): cover `Conflicted` and `Abandoned` FFI conversions

Add tests for `transaction_context_from_ffi` returning `None` for both new arms and for `FFITransactionContextType::from(TransactionContext)` mapping `Conflicted` and `Abandoned` correctly, matching the coverage already in place for the other variants.

Addresses manki-review comment on PR #168
#168 (comment)

* test(key-wallet): serde round-trip coverage for new `TransactionContext` variants

`Conflicted { previous: Box<TransactionContext> }` is a recursive heap type and `Abandoned` is a unit variant — neither was covered by a round-trip test. Lock in the serde shape now so any future change to the derived representation surfaces in unit tests, not in deserialization failures against on-disk wallet state.

Addresses manki-review comment on PR #168
#168 (comment)

* docs(key-wallet): document `Conflicted.previous` active-context invariant

The type permits any variant in `previous`, including another `Conflicted` or `Abandoned`. Making the invariant explicit in the doc comment keeps callers honest until a follow-up either splits the enum or adds a validating constructor.

Addresses manki-review comment on PR #168
#168 (comment)

* fix(key-wallet): guard `TransactionContext` `Display` against deep `Conflicted` nesting

The recursive `Display` impl walked `Conflicted.previous` without a depth bound, so a corrupted on-disk state that deserialized a deep chain could overflow the stack on a routine format call. Walk the chain iteratively and short-circuit beyond a fixed depth.

Addresses manki-review comment on PR #168
#168 (comment)

* test(key-wallet): serde round-trip with inactive spend does not resurrect spent outpoints

Addresses Manki review comment on PR #168
#168 (comment)

* test(key-wallet): gate serde round-trip test on `serde` feature

Addresses manki-review review comment on PR #168
#168 (comment)
…scendant cascade (#169)

* feat(key-wallet): add rewind primitives on managed accounts

Add `demote_records_above`, `demote_record`, and (for the funds variant) `rebuild_utxos` so wallet-side reorg handling can demote above-cut records and rebuild the UTXO / spent-outpoint state from the surviving records. Mirrors the post-deserialization rebuild that the funds account's serde `Deserialize` impl already uses.

The keys variant only needs the context demotions since it carries no UTXO state. Both helpers leave records currently in `InstantSend`, `Conflicted`, or `Abandoned` contexts alone.

* feat(key-wallet): `WalletInfoInterface::rewind_to_height` with descendant cascade

Adds the wallet-level rewind orchestrator that consumes the new per-account demote / rebuild primitives. Validates the chainlock floor up front, runs an initial cut on every account, then drives a cross-account fixed-point loop that demotes any in-wallet record whose inputs spend an output of an already-demoted record. Finally rebuilds every funds account's UTXO state and rolls `last_processed_height` / `synced_height` back to `min(height, current)`.

`used_addresses` markers are intentionally preserved as monotonic state. The trait method returns a `RewindOutcome` so the manager-level emitter can fire a single atomic event per wallet. Self-conflict detection and ISLock-quorum re-validation are explicitly deferred to follow-ups with in-code TODOs.

* feat(key-wallet-manager): `WalletInterface::rewind_to_height` and `get_transaction`

Threads the wallet-side reorg path through the manager:

- new `RewindResult` and `RewindError` types
- `rewind_to_height` validates the chainlock floor across every managed wallet up front, then runs the per-wallet rewind and emits a single `WalletEvent::Reorg` per wallet whose state changed
- `get_transaction` lookup for the auto-rebroadcast path
- `WalletEvent::Reorg` variant with the partitioned demoted-vs-conflicted txid lists and the post-rewind balance

The `&mut self` receiver on `rewind_to_height` is the lock contract: implementations must not yield mid-rewind so concurrent mutators on the same trait surface cannot interleave with a reorg-driven demotion.

Mock implementations in `test_utils` get the minimum stubs needed to satisfy the trait.

* feat(dash-spv-ffi): handle `WalletEvent::Reorg` in callback dispatch

Adds the exhaustive-match arm for the new `WalletEvent::Reorg`
variant. The C ABI does not yet carry a dedicated reorg callback,
so the arm logs intent via a TODO and drops the event. Wiring a
real callback is tracked separately.

* test(key-wallet-manager): cover `rewind_to_height` and `get_transaction`

Adds seven scenarios behind the new `WalletInterface`:

- single tx at H+1 demotes from `InBlock` to `Mempool` and emits a
  `WalletEvent::Reorg` with zero confirmed balance
- TxA at H+1, TxB spending TxA at H+2 cascades both
- IS-locked record is retained as `InstantSend` through the rewind
- rewind below the chainlock floor is rejected with the correct
  error fields and a rewind at the floor itself is allowed
- `last_processed_height` and `synced_height` roll back, and a
  rewind above the current height does not advance them forward
- `get_transaction` returns stored records and `None` for unknown
  txids

* chore(key-wallet-manager): re-sort imports after rewind addition

* chore: pr cleanup — import, visibility, and comment fixes

- Import `RewindError` at module top in `event_tests.rs` instead of using `crate::wallet_interface::RewindError` inline
- Use imported `WalletId` instead of `crate::WalletId` in `wallet_interface.rs`
- Replace em-dash prose separators in doc comments with `:` or `;`
- Remove caller-reference sentence from `spend_from` doc
- Rewrite `state_changed` field doc to describe the field, not its callers

* fix(key-wallet): make `rebuild_utxos` order-independent and restore `is_trusted`

Guards every UTXO insert against `spent_outpoints` so a record whose spending child sorts earlier (e.g. two records demoted to `Mempool` after a rewind, both with `None` heights) cannot resurrect a spent outpoint into the spendable set.

Restores `is_trusted` on self-send change outputs by detecting `has_owned_input` against the rebuild's own `spent_outpoints` and matching against the account's internal pool. Post-rewind self-send change outputs that demote to `Mempool` continue to bucket into `confirmed` rather than silently undercounting.

Addresses manki-review on PR [#169](#169) ([thread](#169 (comment)), [thread](#169 (comment))).

* fix(key-wallet): require `rewind_to_height` and bound cascade work

Removes the no-op default body of `WalletInfoInterface::rewind_to_height`. The default returned `Ok(RewindOutcome::default())` with `state_changed = false`, which silently suppresses the `WalletEvent::Reorg` event and leaves UTXOs unrebuilt — a correctness-critical mutation cannot be advisory. Any implementor that forgets to override would silently appear to succeed.

Replaces the descendant cascade's full `already_demoted` re-scan with a `frontier` that carries only the previous wave. Any newly reachable descendant must spend an output of that wave, so the prior `O(D²)` loop collapses to `O(D)` for the outpoint collection step. The `already_demoted` set is retained for the candidate-skip predicate.

Addresses manki-review on PR [#169](#169) ([thread](#169 (comment)), [thread](#169 (comment))).

* refactor(key-wallet): delegate funds-account demote helpers to the keys account

`ManagedCoreFundsAccount::demote_records_above` and `demote_record` were verbatim duplicates of the keys-account versions that routed through `self.keys.transactions_mut()`. Collapse the funds versions to one-line delegations so a future correctness fix to the demotion predicate (e.g. also skipping `InstantSend`) only needs to land on the keys account.

Addresses manki-review on PR [#169](#169) ([thread](#169 (comment))).

* test(key-wallet-manager): assert balance and `Reorg` event invariants on rewind

Extends `test_rewind_cascades_to_descendant_in_same_account` to assert the post-rewind balance equals exactly the child output value. The parent UTXO remains claimed by the child (still in `Mempool`), so only the child's output reaches the spendable set. This pins down the order-independence of `rebuild_utxos`: without the `spent_outpoints` insert guard the parent UTXO would resurrect into the spendable set whenever the child's txid sorts before the parent's.

Extends `test_rewind_below_current_does_not_advance_heights_upward` to subscribe to events before the no-op rewind and assert no `WalletEvent::Reorg` was emitted. This pins down the `state_changed` guard that suppresses spurious events.

Addresses manki-review on PR [#169](#169) ([thread](#169 (comment)), [thread](#169 (comment))).

* fix(key-wallet): make `has_owned_input` order-independent in `rebuild_utxos`

`has_owned_input` was checking `spent_outpoints`, which only sees
inputs of already-processed records. A mempool child sorted before
its block parent would observe an empty set, so `is_trusted` never
got restored and trusted change still landed in `unconfirmed` after
rewind.

Collect every owned outpoint from the active records in a pre-pass
and use that set for the check, decoupling the signal from sort
order.

Addresses manki review comment on PR #169
#169 (comment)

* test(key-wallet-manager): cover three-level cascade and `is_trusted` restoration on rewind

Add `test_rewind_cascades_three_levels` so a regression that drops
descendants past the first wave of the frontier cascade is caught
by tests, and `test_rewind_restores_is_trusted_on_mempool_change`
to lock in the `has_owned_input` fix by paying change back to an
internal-pool address and asserting the demoted output lands in
`confirmed()` rather than `unconfirmed()`.

Addresses manki review comments on PR #169
#169 (comment)
#169 (comment)

* test(key-wallet-manager): assert post-rewind balance in three-level cascade

Adds a `confirmed() + unconfirmed()` assertion to `test_rewind_cascades_three_levels` so a `rebuild_utxos` regression that double-removes the intermediate `tx_b`'s output or resurrects a spent ancestor is caught by the test, not just frontier-propagation regressions.

Addresses manki-review review comment on PR #169
#169 (comment)
#170)

* feat(key-wallet-manager): add `WalletEvent::TxRepeatedlyOrphaned` variant

New variant emitted by the SPV mempool manager when an outgoing
transaction demoted by reorg has exhausted its rebroadcast retry
budget. UI consumers surface this so the user can decide to abandon,
re-sign, or wait.

The `wallet_id` field is `Option<WalletId>` because the rebroadcast
loop may emit this for a txid that is no longer attributable to any
managed wallet. `WalletEvent::wallet_id()` is widened to
`Option<WalletId>` to accommodate.

* feat(dash-spv): drive wallet rewind and per-wallet effective floor on `ChainReorg`

`FiltersManager` now calls `WalletInterface::rewind_to_height` when it
observes a `ChainReorg` event, then resets its filter scan cursor to
the effective per-wallet floor (`min(fork_height, wallet.synced_height)`)
instead of always to `fork_height`. A wallet whose ingest tip is behind
the fork never saw the orphaned chain, so the filter pipeline does not
need to re-match the segment between its tip and the fork.

If the wallet refuses the rewind (e.g. chainlock floor, defense in depth since the SPV cascade already enforces this upstream), log and fall back to `fork_height` for the floor without touching wallet state.

Renames `reset_for_reorg`'s parameter to `effective_floor` to make the
contract explicit.

* feat(dash-spv): auto-rebroadcast reorg-demoted transactions with safety checks

`MempoolManager` now subscribes to `WalletEvent` broadcasts and drives
an auto-rebroadcast pipeline for transactions demoted by a chain reorg:

- `WalletEvent::Reorg` enqueues each `demoted_txid` after a
  chainlock-floor defense check (the wallet already refuses to rewind
  below its chainlock floor upstream, this branch is belt-and-braces).
- Block-height `nLockTime` is compared against the current chain tip;
  unsatisfied locktimes defer the broadcast. Timestamp locktimes are
  compared best-effort against wall clock, never against an MTP cache
  the SPV side does not maintain.
- An input-conflict check evicts pending entries whose inputs collide
  with any transaction confirming on the new chain (delivered via
  `WalletEvent::BlockProcessed`).
- Each attempt advances an exponential backoff capped at one hour.
  After `MAX_REORG_REBROADCAST_ATTEMPTS` (10) failures the entry is
  dropped and a `WalletEvent::TxRepeatedlyOrphaned` is emitted so
  the UI can surface it for user intervention.

The manager owns a forwarder `broadcast::channel` so it can both subscribe to wallet events (replaceable via `set_wallet_event_subscription`) and emit `TxRepeatedlyOrphaned` without changing the trait surface.

The constructor gains a `chainlock_height: Arc<AtomicU32>` parameter threaded through from `lifecycle.rs`. `MempoolManager` also picks up a `current_tip_height` field updated via `SyncManager::update_target_height`.

Tests cover the happy path, input-conflict eviction, locktime defer
followed by tip advance, and the retry-cap orphan event.

* chore(dash-spv-ffi): mark new `TxRepeatedlyOrphaned` variant as not-yet-bridged

Add the missing match arm in `FFIWalletEventCallbacks::dispatch`. The
variant is left without an FFI surface for now (matching the existing
`Reorg` arm) so the C ABI stays unchanged. Wiring a dedicated callback
is deferred to a follow-up that surfaces orphaned-tx state to the UI.

* chore: pr cleanup: fix FQ paths and remove what-comments

* test(dash-spv): cover rebroadcast backoff, chainlock floor, and channel-driven paths

Addresses four `manki-review` testing-coverage findings on PR #170:

- Extend `test_reorg_demoted_tx_rebroadcast_happy_path` with a second immediate `drive_reorg_rebroadcast` call to assert the exponential backoff suppresses a duplicate broadcast and does not bump `attempts`.
- Add `test_chainlock_floor_prevents_enqueue` exercising the chainlock-floor defense in `enqueue_demoted_for_rebroadcast` when `chainlock_height > current_tip_height`.
- Add `test_input_conflict_via_channel_evicts_rebroadcast` so the `WalletEvent::BlockProcessed` arm of `drain_wallet_events` is exercised through the channel, not by calling `evict_conflicting_rebroadcasts` directly.
- Extend `test_block_processed_removes_confirmed_txids` to seed `pending_rebroadcast` via `enqueue_demoted_for_rebroadcast` and assert the confirmed txid is removed from it, locking in the `BlockProcessed` handler's `pending_rebroadcast.remove(txid)` line.

Promotes `enqueue_demoted_for_rebroadcast` to `pub(super)` so sibling-module tests can drive the rebroadcast queue through the public API.

* ci: trigger workflows on feat/filter-rematch-and-rebroadcast

* test(dash-spv): narrow `enqueue_demoted_for_rebroadcast` visibility and clarify chainlock-floor test

Keep `enqueue_demoted_for_rebroadcast` private and expose a `#[cfg(test)] pub(super)` wrapper so sibling-module tests in `sync_manager.rs` can seed `pending_rebroadcast` without widening production visibility.

Drop the unused `wallet_event_rx` / `set_wallet_event_subscription` setup from `test_chainlock_floor_prevents_enqueue` (the test exercises the direct call, not the channel path) and document the ordering invariant the `transactions` postcondition relies on (the guard must short-circuit before the wallet fetch/insert path).
* test(dash-spv): add dashd integration tests for reorg cascade

Drives real chain reorgs against a single regtest dashd via
`invalidateblock` + `generatetoaddress` and asserts the
[`SyncEvent::ChainReorg`](https://github.com/xdustinface/rust-dashcore/blob/dev/dash-spv/src/sync/events.rs)
and [`WalletEvent::Reorg`](https://github.com/xdustinface/rust-dashcore/blob/dev/key-wallet-manager/src/events.rs)
events fire with the expected `fork_height` and a bumped generation
counter.

Exposes `invalidate_block`, `reconsider_block`, and `get_block_hash` on
`DashCoreNode` so the new module can drive forks without reaching into
the underlying RPC client.

Phase 6 of [#147](#147).
Cases that need infrastructure that is not yet present in the harness
(CLSIG injection, masternode-backed LLMQs for IS-locks and DIP-24
rotation, post-cascade downstream resync) ship as scaffolded
`#[ignore]` tests with a link to the gating issue so the matrix stays
visible and can be re-enabled in place once the gap is closed.

* test(dash-spv): handle `RecvError::Lagged` in reorg event waiters

Map `broadcast::error::RecvError::Lagged` to a logged `continue` in `wait_for_chain_reorg` and `wait_for_wallet_reorg` so a saturated broadcast buffer surfaces as a clear timeout instead of an opaque panic. The closed-channel branch still panics with a more specific message.

Addresses manki-review comment on PR #171
#171 (comment)
@github-actions
github-actions Bot force-pushed the chore/refresh-masternode-seeds branch 2 times, most recently from 4d796bc to ed4a2b9 Compare June 8, 2026 07:50
@github-actions
github-actions Bot force-pushed the chore/refresh-masternode-seeds branch 2 times, most recently from 7a0e51a to e40d443 Compare June 22, 2026 08:09
@github-actions
github-actions Bot force-pushed the chore/refresh-masternode-seeds branch from e40d443 to c2586bc Compare June 29, 2026 07:55
@github-actions
github-actions Bot force-pushed the chore/refresh-masternode-seeds branch from c2586bc to 2bf9285 Compare July 13, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants