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
Conversation
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
Fix trivial underflow/overflow issues
* 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>
…sed Cbf arms AI-assisted (Claude Code).
Public signatures unchanged. AI-assisted (Claude Code).
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).
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The CBF chain source and the payment-agnostic chain-service hooks the
node-app-ldk-nodeapp 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-nodepins in itsCargo.lock(rev = 880b105).CBF chain source
A fourth
ChainSourcevariant backed by BIP157 compact block filters over kyoto, alongside the existing bitcoind, Esplora and Electrum sources.sync_walletswaits for the applied tip rather than returning early.Chain-service hooks
ChainServiceHookslets an embedder supply fee estimates and transaction broadcasting from outside the node, with no payment knowledge in this crate — a hook returnsOk/Errand the fork falls through to its configured behavior onErr(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_heightlets a fresh CBF wallet start from a recent block instead of genesis.resolve_birthdayanchors 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.Nonestill means genesis, and the birthday applies only while the wallet DB is genesis-rooted.Wallet correctness fixes
swap_tx_confirmationsqueried 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
ChainSourcematches with fail-closedCbfarms, sync swap wrappers bridged over now-async wallet operations, and the upstream broadcast/classify API change.Verification
cargo testgreen. 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