Skip to content

feat(cbf): BIP157 chain source, payment-agnostic chain-service hooks and a wallet birthday anchor (superset of #5 cycles) - #6

Open
datphamcode295 wants to merge 172 commits into
lamtuanvu:mainfrom
datphamcode295:cycles-cbf-828
Open

datphamcode295 wants to merge 172 commits into
lamtuanvu:mainfrom
datphamcode295:cycles-cbf-828

Conversation

@datphamcode295

Copy link
Copy Markdown

The CBF chain source and the payment-agnostic chain-service hooks the node-app-ldk-node app builds its three chain modes on. Superset of PR #5 (cycles) and PR #3 (swaps), on top of the upstream sync in PR #4.

This is the branch node-app-ldk-node pins in its Cargo.lock (rev = 880b105).

CBF chain source

A fourth ChainSource variant backed by BIP157 compact block filters over kyoto, alongside the existing bitcoind, Esplora and Electrum sources.

  • Crash-safe block delivery with batched chain persistence.
  • Package broadcasting, and a bounded handoff to the kyoto P2P broadcast queue so one un-pulled transaction cannot wedge it.
  • sync_wallets waits for the applied tip rather than returning early.
  • Clean shutdown: the chain source stops before the runtime waits on its tasks, and a restart aborts if the node stopped during backoff.
  • Trusted peers may be hostnames; unparseable entries error instead of being dropped.
  • A simplified public sync status, so a caller can tell "synced" from "still scanning" without reading kyoto's internals.

Chain-service hooks

ChainServiceHooks lets an embedder supply fee estimates and transaction broadcasting from outside the node, with no payment knowledge in this crate — a hook returns Ok/Err and the fork falls through to its configured behavior on Err (block-derived estimates for fees, P2P for broadcast). The app layer is what turns those hooks into L402-paid calls.

Fee targeting was corrected to 3 blocks for channel funding transactions so they actually confirm in time.

Wallet birthday

wallet_rescan_from_height lets a fresh CBF wallet start from a recent block instead of genesis. resolve_birthday anchors on the highest compiled checkpoint strictly below the birthday, and this branch compiles a third mainnet anchor at block 965,999 next to the existing segwit (481,823) and taproot (709,631) ones, which is what cuts a new node's first filter sync from hours to minutes. None still means genesis, and the birthday applies only while the wallet DB is genesis-rooted.

Wallet correctness fixes

  • An own send is applied as unconfirmed immediately, and a rejected broadcast releases the inputs it reserved, so a refused transaction does not strand funds.
  • A replay that can never reach a listener fails closed; an unprovable replay below a listener's tip is not treated as divergence.
  • swap_tx_confirmations queried the txid in reversed byte order against bitcoind.

Carried from the stacked branches

The swaps and cycles work from PRs #3 and #5, adapted to current LDK APIs: four-variant ChainSource matches with fail-closed Cbf arms, sync swap wrappers bridged over now-async wallet operations, and the upstream broadcast/classify API change.

Verification

cargo test green. Exercised end to end by the three chain modes in econ-v1/node-app-ldk-node#19, the Chain console in econ-v1/node-app-wallet#18, and an 18-scenario regtest E2E suite in econ-v1/node#2920.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GsD9BAUDMyUYTheV4X9ZD3

tnull and others added 30 commits June 10, 2026 11:05
Validate splice-out requests against outbound capacity after converting the requested satoshi amount to millisatoshis with overflow handling. This prevents values above the spendable channel balance from slipping past the guard due to a unit mismatch.

Keep splice integration coverage aligned with the corrected capacity semantics by rejecting an amount one satoshi above outbound capacity and deriving the full-cycle splice-out amount from the channel's current spendable capacity.

AI-Assisted-By: OpenAI Codex

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe
A cancellable task spawned during shutdown could otherwise outlive the shutdown sequence instead of being cancelled with the rest of the cancellable runtime work. Reject late spawns while shutdown is draining tasks and reopen that path when a stopped node starts again.

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe
Replace per-protocol single-LSP configuration `LSPS1Client` and `LSPS2Client`
with a unified `Vec<LspNode>` model where users configure LSP nodes via
`add_liquidity_source()` at build time or runtime and per-LSP protocol
support is discovered via the LSPS0 `list_protocols`.

- Introduce a per-LSP `trust_peer_0conf` flag to `LspConfig`/`LspNode`
  structs that controls whether 0-conf channels from that LSP are accepted
- Add LSPS0 protocol discovery `discover_lsp_protocols` with event
  handling for `ListProtocolsResponse`
- Update events to also use each LSP's `trust_peer_0conf` flag
  when deciding whether to allow 0-conf channels
- Replace `set_liquidity_source_lsps1` and `set_liquidity_source_lsps2`
  builder methods with a single `add_liquidity_source()` that takes a
  `trust_peer_0conf` flag
- Rename `set_liquidity_provider_lsps2` to `enable_liquidity_provider`
- LSPS2 JIT channels now query all LSPS2-capable LSPs and automatically
  select the cheapest fee offer across all of them
- Spawn background discovery task on `Node::start()` and expose a watch
  channel so dependent flows can wait for discovery to complete
- Add a new `Liquidity` handler `Node::liquidity()` exposing `add_liquidity_source()`
  API for adding LSPs at runtime, and `lsps1()` for the existing LSPS1 surface
Remove the `Ignoring` variant now that the liquidity source is
always built, so the enum and its match arms are now pure
overhead. Replace it with a struct that holds the `LiquiditySource`
directly and have each trait method delegate straight to
`liquidity_manager()`.
…cellable-shutdown

Prevent late cancellable runtime tasks during shutdown
…out-capacity-units

Reject oversized splice-out amounts
Clarify that public APIs remain unstable before 1.0 while persisted
node state is intended to remain readable by newer releases.

Co-Authored-By: HAL 9000
Add a downgrade canary that writes current node state through the legacy
v1 filesystem store and reopens it with ldk-node v0.7.0. This monitors
whether serialized node, channel, and payment state remains usable by
v0.7.0, including a restored channel and a post-restart payment.

This does not assert that the current filesystem-store v2 IO layout can
downgrade to v0.7.0's v1 layout. That IO-layer downgrade is unsupported:
v2 stores empty namespaces under [empty], which v1 readers do not look
up.

Co-Authored-By: HAL 9000
…antees-downgrade-070

Document compat. guarantees, monitor serialization compat
Refactor liquidity source to support multiple LSP nodes
Electrum transaction sync now reuses the client already shared by BDK
and direct Electrum calls. This avoids opening a second Electrum
connection and completes the reuse intended by lightningdevkit#488.

Co-Authored-By: HAL 9000
…ctrum-client-reuse

Reuse Electrum client for transaction sync
Switches vss-client-ng to the crates.io 0.6 release.

Generated with OpenAI Codex.
Move repeated VssStore construction logic into a shared
build_vss_store() helper and have existing tests use it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract the single-page VSS listing logic into a list_keys method
that accepts page_token and page_size parameters. list_internal
now drives the pagination loop itself, calling list_keys per page.

This prepares for PaginatedKVStore support which will reuse
list_keys for single-page queries.

This also fixes a potential issue where if the VSS server returned None
for the page token we could enter into an infinite loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Share the common BOLT11 payment send flow between fixed-amount and
explicit-amount sends so follow-up API variants can reuse the same
payment-store and error handling path.

AI-Tool-Disclosure: Created with OpenAI Codex.
Add a BOLT11 payment API for sending less than the invoice amount while
using the invoice amount as the declared total MPP value. Cover the path
with an integration test where two nodes each pay half of one invoice and
the receiver claims the full amount.

AI-Tool-Disclosure: Created with OpenAI Codex.
Return InvalidAmount when converting the requested satoshi amount to
millisatoshis would overflow. This keeps debug and release behavior
consistent and avoids producing a URI whose on-chain amount differs from
its Lightning payment amount.

This commit was created with assistance from OpenAI Codex.

This finding was discovered by Project Loupe
Use saturating arithmetic when accounting for skimmed JIT-channel fees
while validating manually claimed payments. This prevents an oversized
skimmed fee from underflowing the expected claimable amount.

This commit was created with assistance from OpenAI Codex.

This finding was discovered by Project Loupe
Track registered transaction IDs in a set so repeated filter
registrations do not grow the collection or slow block-connected checks.
This keeps the wallet's registered-transaction lookup bounded by unique
transaction IDs.

This commit was created with assistance from OpenAI Codex.

This finding was discovered by Project Loupe
febyeji and others added 30 commits July 17, 2026 05:49
* bump kyoto version

* Add `synced_to_tip` to CbfSyncState

Previously we did not track the `FiltersSynced` kyoto event, so we could
not tell when we had applied all blocks up to the tip. For example, when
we stop and restart the node, kyoto's tip is 0 at the instant of start
(it does not persist its chain), so our applied height trivially matches
kyoto's tip and we would falsely conclude we had reached it. That is only
actually true once we have received `FiltersSynced`.

* Add lookahead addresses to `list_revealed_scripts`.

Now the function is called `list_watched_scripts`.

* Add timeout to block fetch attempts.

Previously stalled fetch would hang indefinitely.

---------

Co-authored-by: Alexander Shevtsov <randomlogin76@gmail.com>
After the new kyoto release (v0.6.3) `Indexedfilter` event has a
`header` field which is used directly (previously we fetched header as
an additional action).

Also renamed import of kyoto `Event` into `KyotoEvent` for readability.
The ChannelFunding confirmation target resolved to a 12-block fee tier,
which during normal mempool congestion maps to a rate low enough that
funding transactions can sit unconfirmed for hours. The channel never
reaches channel_ready and the UI is stuck on `sync`.

Lower the target to ~3 blocks (mempool's "fast" tier) so funding txs are
mined promptly. The fee is still sourced from the chain source's
recommended estimates (esplora get_fee_estimates / electrum estimatefee /
bitcoind estimatesmartfee) — only the targeted confirmation window
changes, and only for funding transactions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…yte order

getrawtransaction wants the txid in RPC/display (big-endian) order (Txid Display),
but serialize_hex(txid) emits internal little-endian (reversed) bytes → bitcoind
returns -5, mapped to Ok(None)=NotFound. The native swap watcher therefore never
saw a confirmed opening tx on the bitcoind backend, so the CSV/confirmation ladder
never armed and swaps wedged at AWAIT_CONFIRM/AWAIT_CLAIM_PAYMENT. Fix: pass
txid.to_string() (display order). Proven on regtest (display txid=10 confs, reversed=-5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…swaps` feature

Add the on-chain building blocks the native PeerSwap engine (modules/ldk-node in
the consumer) needs, all gated behind a new `swaps` cargo feature so the default
build is byte-for-byte unaffected:

- B1–B3 funding: create_swap_funding_tx (P2WSH HTLC opening, signed, not broadcast),
  swap_list_confirmed_utxos, swap_sign_psbt (wallet/mod.rs).
- B4 broadcast: broadcast_swap_tx over the bounded broadcast queue (tx_broadcaster.rs).
- B5 reorg-aware per-txid confirmation tracking: watch_txid + get_tx_confirmations
  → TxStatus/ChainStatus + derive_tx_status, with a swap_query_tx backed by whichever
  chain source is configured (Esplora/Electrum/Bitcoind) and FAIL-CLOSED
  (NoChainSource) when it cannot answer (chain/mod.rs, chain/electrum.rs).
- B6 feerate: estimate_onchain_feerate → source-bearing FeerateQuote so callers can
  refuse a stale/fallback estimate (fee_estimator.rs).
- B7 discovery: swap-capability custom gossip plumbing (custom_gossip.rs).
- Builder wiring (builder.rs), public surface (lib.rs).

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

Adds the cooperative cycle-balance primitive: `Node::send_along_route(route,
amount_msat, payment_hash, preimage)` sends a spontaneous payment along a
caller-supplied route back to self, recorded as `PaymentKind::Rebalance`
(TLV type 12, ungated for record compatibility). The `PaymentClaimable`
handler scopes the circular-payment and spontaneous-duplicate guards to
exclude Rebalance records and claims the looped HTLC inline with the
locally-held preimage, marking the single outbound record Succeeded —
settlement is observed by polling the payment store, no user-facing event
is emitted. Only the `Node` method is gated behind the new `cycles`
feature; the default build is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aim amount

send_along_route sent the final onion with RecipientOnionFields::
spontaneous_empty() and no keysend TLV, so lightning's final-hop parser
(create_recv_pending_htlc_info) failed every looped HTLC with "We
require payment_secrets" BEFORE PaymentClaimable could fire — the
patched claim path was unreachable and no cycle could ever settle
(fail-safe, but feature-dead). Fix: register the hash with the
ChannelManager's STATELESS inbound-payment verifier
(create_inbound_payment_for_hash, min_value_msat = amount) and send
secret_only(payment_secret). No payment-store record is created, so the
scoped circular guard still sees only the single Outbound Rebalance
record; the secret never leaves the onion we build, so nobody else can
construct a claimable HTLC for the hash.

event.rs: belt-and-braces amount pin in the Rebalance claim — never
claim_funds (= reveal the preimage) for less than the recorded loop
amount; fail the HTLC backwards instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No logic changes; produced by the mandated `cargo fmt --all` run.

AI disclosure: formatting applied during an AI-assisted session (Claude Code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Track upstream signature changes: RecipientOnionFields moved to
ln::outbound_payment and secret_only() now takes the amount;
create_inbound_payment_for_hash() gained a payment-metadata parameter
and returns a tuple; the payment store is async (block_on at this sync
call site); running state is checked via is_running instead of the
runtime slot.

AI disclosure: change verified and committed in an AI-assisted session
(Claude Code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
P0 (crash safety): ChainListener now classifies every incoming block
against each listener's BlockLocator (Deliver / AlreadyApplied /
Diverged). Replayed blocks after a restart are skipped per listener
instead of tripping ChannelManager's chain-order assert; a genuinely
diverged chain latches an error that fails the CBF sync loop loudly
instead of panicking or silently advancing monitors on a stale fork.
The on-chain wallet stays ungated because BDK expects reconnection from
the point of disagreement. The CBF chain-op queue is now bounded
(depth 64) with backpressure on the kyoto event side.

P1 (write amplification): the wallet persister can defer local_chain
writes (bulk mode) while tx_graph/indexer keep writing through
synchronously, since only local_chain grows quadratically and it is
reconstructible. The CBF applicator enables bulk mode for the life of
the sync loop and flushes chain state every 2016 blocks, on divergence,
and on shutdown. On a crash the wallet resumes at most one flush
interval back; no funds data is lost.

Adds 8 unit tests covering the classifier (replay, fork at same height,
ancestor replay, gap) and persister deferral/flush/failure-retry paths.

AI disclosure: implemented with AI assistance (Claude Code), reviewed
in two adversarial rounds by Codex; classifier hash-awareness fix came
out of round 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`NodeBuilder::set_chain_source_cbf` gains `wallet_rescan_from_height`:
the lowest height whose block the wallet must still scan. Because CBF
has no chain backend at build time, the height resolves to the highest
bip157 compiled checkpoint STRICTLY below it (mainnet: 481,823 /
709,631, each one block before its softfork activation; other networks
scan from genesis). Strictly below because everything downstream treats
the anchor as already applied and scans from anchor+1. `None` keeps
today's scan-from-genesis behavior.

The resolved locator flows through `new_cbf` as the initial chain tip:
a fresh wallet inserts it as its first BDK checkpoint — now persisted
immediately, since `apply_update` only stages and a crash before the
first block would resurrect a genesis-rooted wallet beside listeners
persisted at the birthday, which the divergence gate then latches on
every start. The residual creation window is healed on load: a CBF
wallet whose chain state never persisted a block past genesis is
re-anchored at the deterministic compiled checkpoint.

`resume_checkpoint`'s walk-back is extracted into `resume_anchor`,
which never steps onto genesis (the fresh chain [genesis, birthday]
previously slid to genesis, returned None, and kyoto rescanned every
filter from block 1), and the anchor is clamped to the furthest-behind
listener so a mixed state cannot stall the applicator on blocks it
refuses to apply.

The birthday is an explicit input by design: NodeEntropy carries no
provenance, so the library cannot distinguish a freshly generated seed
from a restored one; only the caller that created the seed can safely
choose an anchor above genesis.

Adds 4 unit tests (strict boundary, mainnet-only resolution, dense
walk-back, sparse never-genesis). cargo test --lib: 93 passed.

AI disclosure: implemented with AI assistance (Claude Code); reviewed
adversarially by Codex — the strict-below boundary, the genesis
fallback in resume, the unpersisted-checkpoint crash window, and the
anchor-above-listener clamp all came out of those review rounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Public signatures unchanged. AI-assisted (Claude Code).
…ever await

WalletSource::list_confirmed_utxos/sign_psbt are async fns whose bodies
(list_confirmed_utxos_inner/sign_psbt_inner) are plain sync code with zero
await points. Routing them through Runtime::block_on -> block_in_place
panics on a current-thread runtime (e.g. any #[tokio::test] caller).
swap_list_confirmed_utxos/swap_sign_psbt now call the sync *_inner helpers
directly. create_swap_funding_tx keeps the block_on bridge since
create_funding_transaction genuinely awaits the persister.

AI-assisted (Claude Code).
…s test

Pre-existing test-only compile break (unrelated to CBF): the swap_b7_tests
call to lightning::sign::KeysManager::new was missing the 4th
v2_remote_key_derivation bool that upstream lightning added; every other
call site in this crate already passes it. Without this fix, `cargo test
--features swaps,cycles` cannot compile at all, blocking verification of
the CBF hostname-peers and chain-service-hooks work in this branch.

AI-assisted (Claude Code).
… broadcast

App-supplied async callbacks tried before native fee sources / P2P broadcast.
AI-assisted (Claude Code).
Task review of the payment-agnostic ChainServiceHooks work (d4b32b8) found
1 Critical + 4 Important findings. This fixes all of them:

- C-1: ChainServiceHooks (plus FeeEstimatesFuture, BroadcastFuture,
  CbfFeeSourceConfig) lived in the private `chain` module with no
  re-export, so a downstream crate could not name the type to call
  NodeBuilder::set_cbf_chain_service_hooks (E0422). Added additive-only
  `pub use chain::{...}` re-exports in src/lib.rs, matching these types'
  own ungated visibility (not gated behind the `swaps` feature). Verified
  `git diff a6f60a7..HEAD -- src/lib.rs` still contains only added lines.

- I-1: process_broadcast_package early-returned once the kyoto runtime was
  Stopped, before ever trying the broadcast hook -- exactly when an
  external relay might be the only way to get a time-sensitive
  transaction (e.g. a force-close) out. Restructured to capture an
  Option<Requester> and always invoke dispatch_broadcast; the P2P leg
  itself now degrades to an error log when there is no live requester,
  instead of gating the whole broadcast attempt.

- I-2: the hook awaits were the only unbounded awaits in the fee-update
  and broadcast-drain paths (Esplora/Electrum are already timeout-wrapped),
  so a hung hook could stall the fee cycle or the broadcast queue
  indefinitely. Added CHAIN_SERVICE_HOOK_TIMEOUT_SECS (10s, matching the
  file's existing external-service timeouts) and wrapped both hook
  invocations in tokio::time::timeout; a timeout is treated identically to
  Err(()). Threaded hook_timeout as an explicit Duration parameter (rather
  than a hardcoded const) so tests can inject a short timeout without
  needing tokio's paused-clock test-util feature (no Cargo.toml change).

- I-3 / I-4: a sparse or empty fee_estimates hook map was previously
  applied as a full cache replacement, silently pinning any uncovered
  target to the crate's static fallback (worse than any pre-hook source)
  and, for an empty map, still stamping the fee-cache timestamp. Added
  required_hook_fee_targets() (the six distinct block-count targets the
  estimator needs) and made application all-or-nothing: a hook result
  missing a finite value for even one required target is rejected in full
  and treated exactly like Err(()) -- same fallback path, so the timestamp
  is never stamped from a rejected result. Documented the required target
  set on ChainServiceHooks::fee_estimates.

- Input validation (folds into I-3): accepted hook sat/vB values are now
  clamped to [CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB]
  = [1.0, 10_000.0] before unit conversion, fixing both the `.max(1.0)`
  floor being applied 250x too weak (it was checked in sat/kwu, not
  sat/vB) and a reachable overflow panic in a downstream
  to_sat_per_vb_ceil() read from an unclamped extreme hook value.

Tests: rewrote/extended the chain_service_hooks test group in
src/chain/cbf.rs (12 tests total) to cover: full-map application per
ConfirmationTarget, sparse-map fallback, empty-map fallback (with no
fallback-cache mutation), out-of-range value clamping, fee-hook timeout
fallback, broadcast-hook timeout fallback, and broadcast-hook success when
no requester is available (kyoto stopped).

Gates: cargo test --features swaps,cycles --lib (121 passed, 0 failed),
cargo check --features swaps,cycles (exit 0), cargo check (exit 0),
cargo fmt --all. git diff a6f60a7..HEAD -- src/lib.rs remains additive
only.

AI disclosure: implemented with Claude Code (Anthropic) per this repo's
CLAUDE.md AI-disclosure convention, addressing reviewer findings from an
automated task review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds `pub enum CbfSyncStatus { Syncing, Synced, Failed }` (chain/mod.rs,
re-exported at the crate root) and `Node::cbf_sync_status(&self) ->
Option<CbfSyncStatus>` — `None` for non-CBF chain sources. Deliberately does
NOT publicize the internal, error-carrying `CbfSyncState` (still private to
chain/cbf.rs); the mapping is a pure `simplify_sync_state` function so it's
unit-testable without a live kyoto node. Additive only: `git diff
bfb87e3 -- src/lib.rs` shows one new re-export line and one new `impl Node`
method, nothing else touched — no existing public item changed.

This closes the gap the app-side (modules/ldk-node) Task 8 dispatch
identified: the host API's `/api/v2/chain-mode` GET `running.sync_state`
needs to surface a parked CBF restart-loop failure, and the app's
get_sync_status classifier needs the real initial-sync signal rather than
inferring it from the absence of a sync timestamp alone.

AI disclosure: implemented with Claude Code (Claude Fable 5, Anthropic),
under human review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…annot wedge the queue

`Requester::submit_package` does NOT resolve when a transaction reaches the
network. It resolves when a peer PULLS it: kyoto announces the wtxid in an
`inv` and completes the caller's oneshot only from the code path that answers
that peer's `getdata` (bip157 network/peer.rs -> BroadcastQueue::
sent_transaction_payload). A peer that ALREADY HAS the transaction never sends
`getdata` -- Bitcoin Core evaluates AlreadyHaveTx and logs
`got inv: wtx <id>  have peer=N` -- so the future never resolves, and kyoto
attaches no timeout of its own.

process_broadcast_package awaited it unbounded, on the SINGLE SERIAL drain loop
ChainSource::continuously_process_broadcast_queue. One duplicate broadcast
therefore wedged every LATER broadcast for the lifetime of the node.

Re-broadcasting a transaction the network already has is routine, not
exceptional: both sides of a cooperative close broadcast the same closing tx,
both sides of a force close may broadcast the same commitment, and LDK
re-broadcasts unconfirmed transactions on a timer. Measured on a live regtest
pair: the Verification node re-broadcast the cooperative-close transaction its
Pro counterparty had already relayed, Core answered the `inv` with silence, and
from that second on NOTHING the node broadcast ever reached the network --
18+ regenerated force-close `to_remote` sweeps over nine minutes, plus a
brand-new unrelated on-chain send whose capability call returned success and
whose txid does not exist anywhere. Wire counters for the whole session:
inv=122 bytes (two announcements), getdata=61 (one request), tx=246 (one
payload).

Fix: CBF_P2P_BROADCAST_TIMEOUT_SECS (10s, a sibling of the file's existing
CBF_BLOCK_FETCH_TIMEOUT_SECS, which carries the same rationale) plus
`bounded_p2p_handoff`, a free function -- unit-testable without a live kyoto
node, same shape as the existing dispatch_broadcast -- that awaits one
submit_package future under that budget. A submit error keeps the existing
log_error!; expiry logs at INFO, because the common cause is benign.

Timing out does not retract the announcement: kyoto keeps the transaction in
its BroadcastQueue, still answers a later `getdata` for it, and re-announces
every pending wtxid to any peer that completes a handshake afterwards. LDK
re-broadcasts on its own timer regardless. The only thing that changes is that
the queue behind it keeps moving.

Public API unchanged (both new items are private). No bip157 change.

Tests: 4 new in chain::cbf::tests -- an un-pulled handoff is abandoned rather
than awaited forever; a serial drain of [duplicate close (never pulled),
sweep lamtuanvu#1, sweep lamtuanvu#2] finishes with BOTH sweeps relayed (the fund-safety
property, in the shape of the failing E2E scenario); a pulled handoff is still
awaited to completion (non-vacuity); a submit error stays a fast, distinct
failure. Reverting the timeout fails exactly the first two and passes the other
two.

Gates: cargo test --lib --features swaps,cycles = 129 passed (125 -> +4);
cargo check --features swaps,cycles --tests clean; clippy finding multiset
identical before/after (git stash + diff), zero findings in the new code;
cargo fmt --all. Live: `make e2e-chain-modes` scenario 10 (force close: CBF
node detects + recovers funds) 561s FAIL -> 60s PASS, bob's ~100k-sat
to_remote recovered, sweep observed in bitcoind's mempool.

AI disclosure: implemented with Claude Code (Claude Fable 5, Anthropic), under
human review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ence

`ListenerAction`'s own doc comment states the design: listener durability is
not synchronized, so the chain source resumes from the MINIMUM height across
listeners and replays blocks to any listener that persisted further ahead.
The classifier then contradicted that design. Below a listener's tip it
proved membership from `BlockLocator::previous_blocks`, and treated BOTH "the
ancestor I hold differs" (a real fork) and "I hold no ancestor that far back"
(undecidable) as `Diverged` -- which halts CBF block application permanently.

`previous_blocks` is `[Option<BlockHash>; ANTI_REORG_DELAY * 2]`, i.e. 12
entries, capped by LDK; a locator restored from persistence can carry none at
all. Real skew is larger than that. Measured live on a healthy regtest node
right after a force-close sweep: wallet/sweeper 7871, ChainMonitor 7888,
ChannelManager 7891 -- a 20-block spread, all three tips on the canonical
chain (both hashes appear in the blocks kyoto downloaded seconds later). The
replay from 7872 therefore hit "no ancestry that far back" on the two
listeners that were AHEAD, and:

    ERROR Halting CBF block application: ChannelManager diverged at height
          7872 (listener at 7891, hash 2fcdb185...). The node must be
          restarted to re-derive a common chain state ...

Restarting does not help -- it re-derives the same minimum and re-halts. It
fired on 11 consecutive restarts and `sync_state` latched `failed` for the
rest of the session on a node whose chain state was fine.

Fix: split the undecidable case out as `ListenerAction::ReplayUnprovable`.
The block is still WITHHELD from that listener (it is not `AlreadyApplied`)
and still logged, but it no longer records a halting divergence. An ancestor
we DO hold that differs remains `Diverged`, as do a wrong parent at
`best.height + 1`, a different block at `best.height`, and a gap.

Nothing is given up. The replay keeps climbing, and when it reaches
`best.height` the classifier compares the block hash exactly, and at
`best.height + 1` the parent hash -- both PROVABLE. A listener genuinely on a
fork is still caught, a few blocks later, by a check that can actually decide
it. What changes is only that we stop halting the whole chain source on a
question we cannot answer, in the exact situation resume-from-minimum is
designed to create.

Tests: `listener_action_reports_divergence_beyond_known_ancestry` becomes
`..._reports_an_unprovable_replay_beyond_known_ancestry` (its intent -- "must
not be assumed to be a safe replay" -- is preserved; the block is still not
delivered). Two added: an ancestry-free locator inside the window is
unprovable, not forked; and an unprovable replay still meets a provable check
at the listener's own tip, where a fork IS reported. Reverting the fix fails
exactly those three and leaves the five fork-detection tests green.

Gates: cargo test --lib --features swaps,cycles = 131 passed; clippy finding
multiset identical to cd8dd58; cargo fmt --all. Live: `make e2e-chain-modes`
10/10 (scenario 8 "failure paths: bad config, bad peers, recovery" 199s FAIL
-> 24s PASS, `sync_state == synced after recovery` on the first poll).

Found while fixing the CBF broadcast wedge (d3fcad0): with force-close sweeps
finally reaching the network, the suite reached a post-sweep restart for the
first time and walked straight into this.

AI disclosure: implemented with Claude Code (Claude Fable 5, Anthropic), under
human review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`ReplayUnprovable` defers a decision it cannot make, on the argument that
the replay keeps climbing and will meet the listener at its own tip, where
the hash comparison is provable. That argument holds only while the chain
being replayed is at least as long as the listener's chain.

It is not, in one geometry: a reorg that leaves the canonical tip BELOW an
ahead listener's persisted tip — deeper than the 12-entry ancestry window,
or any depth at all against a locator restored from persistence with no
ancestors. The replay then ends before it can prove anything, every block
is skipped as unprovable at debug level, no divergence is recorded, and
the node advertises a synced tip while that listener sits on a chain we
can neither extend nor refute. Deferring forever is dropping.

Tally each listener's decisions per replay batch and judge them where the
batch ends — at the tip. A listener that skipped a stretch and then
reconnected is the benign resume-from-minimum case and is reported at info
level (so it no longer depends on debug logging to be visible at all). A
listener that saw nothing but unprovable skips, and whose own tip is at or
above the tip we reached, is stranded rather than lagging: no later replay
of this chain reaches it either. That one is recorded as divergence, which
halts the chain source — the pre-`ReplayUnprovable` behaviour, now applied
only to the case that is a true positive.

Every decision routes through `note_decision` so none can bypass the
ledger, and the ledger resets on `blocks_disconnected`, where the tips it
was tallied against stop existing.

Tests pin both sides: the two stranded geometries trip the detection, and
the behind-listener skew — the one whose false positive bricked CBF on
healthy nodes — still heals. Reverting the detection fails exactly the
three stranded tests; making it fire on any unprovable skip fails exactly
the three healing/control tests.

Assisted-by: Claude Code (Opus 5)
`birthday_checkpoint` knew two mainnet anchors, 481,823 (SegWit) and
709,631 (taproot), so every `wallet_rescan_from_height` above 709,631
rounded down to the taproot anchor and a fresh CBF wallet scanned some
256,000 blocks of filters — hours on Pi-class hardware — for a node
born this month.

Add block 965,999 (mined 2026-09-08; hash cross-checked on mempool.space,
blockstream.info and blockcypher.com on 2026-09-09, ~100 blocks below the
tip) as the newest anchor, in a single `mainnet_anchors` table that also
carries the provenance label `resolve_birthday` logs. A birthday of
966,000 now anchors at 965,999 and scans from 966,000; everything at or
below 965,999 resolves exactly as before, and non-mainnet networks still
return `None`. Tests pin the height, the published hash, ascending
distinct anchors, and the 966,000 → 965,999 resolution end to end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ku7pgAakXpVYe52wSnbRME
…jected broadcast releases its inputs

A CBF chain source has no mempool view, so the on-chain wallet never learned
about its own broadcasts until they confirmed: the coins a just-sent
transaction spent stayed listed as unspent, the next send re-selected them,
and bitcoind refused that second send as an underpaid replacement
("insufficient fee, rejecting replacement"). Three pieces close this:

- `Wallet::send_to_address` applies the signed transaction as unconfirmed
  (and persists it, and records the Pending on-chain payment) BEFORE the
  broadcast queue takes it, so two back-to-back sends never race the queue
  and the second one spends the first one's unconfirmed change.
- `CbfChainSource::process_broadcast_package` now receives the on-chain
  wallet and applies every transaction the hook or P2P carried (LDK's
  funding/sweep transactions included; BDK keeps the relevant ones).
- The broadcast hook can now say WHY it declined: `BroadcastHookError::
  Unavailable` falls through to P2P as before, while `Rejected(txid,
  reason)` — the service's bitcoind refusing our transaction — is final:
  no P2P relay, and the rejected transactions are evicted so their inputs
  become spendable again. `BroadcastOutcome::Rejected` carries it.
- `Node::evict_unconfirmed_txs` / `Node::rebroadcast_unconfirmed_tx` let
  the app's transaction watcher drop or re-queue a transaction the network
  has since lost.

Tests: the dispatch decision for a rejected hook, and the BDK semantics the
fix relies on (own spend locks its inputs and exposes its change; the next
build chains on that change; eviction hands the inputs back; a reload from
the persisted change set keeps all of it).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ku7pgAakXpVYe52wSnbRME
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.