Skip to content

Add chain syncing via compact block filters (BIP 157) - #828

Open
febyeji wants to merge 19 commits into
lightningdevkit:mainfrom
febyeji:add-cbf-chain-source
Open

febyeji wants to merge 19 commits into
lightningdevkit:mainfrom
febyeji:add-cbf-chain-source

Conversation

@febyeji

@febyeji febyeji commented Mar 15, 2026 •

Copy link
Copy Markdown
Contributor

This PR adds a new compact block filter chain source (via kyoto) to ldk-node. Let's go through its features.

Design notes

Contrary to other chain sources, we use a push-based model: we don't proactively request data, but rather listen
for events and process them.

Kyoto

Kyoto is a standalone node which comes in the form of a library. It manages peers and provides users with compact
block filters as well as the ability to request blocks on demand.

Getting data from kyoto is done via a channel which emits events (e.g. IndexedFilter which means we received a new
compact block filter).

Operation

In broad terms we operate in the following way:

  1. On start, build and run the kyoto node
  2. Listen for kyoto events
  3. Process kyoto events and respectively apply needed operations (e.g. download full block to apply it).

There is no concept of "active syncing": when the chain source runs, it always processes new events and applies them.
Method sync_wallet waits until the application of all blocks up to the known tip.

Node aliveness

As we are responsible for always having an operational node, we have its runtime status, and if it goes down by any
reason, we try to restart the kyoto node. If something went wrong and after MAX_RESTART_RETRIES=5 the node still died,
we give up.

Scripts

CBFChainSource tracks watched scripts provided from the onchain wallet as well as the lightning wallet.

We need to track two types of scripts: ones provided from the onchain wallet (e.g. to fund the lightning node) and
"LDK-scripts", exposed and needed to be tracked during channel operation (e.g. channel force-close transactions). The
former scripts are revealed via bdk wallet. We don't maintain a CBFChainSource-related copy of them, rather we just ask
bdk wallet each time we need them. This is done to have a single source of truth.

Thus, registered_scripts are LDK-revealed scripts.

Block applicator

This entity applies blocks to the state of the node. (Perhaps its title can be changed to something more concise.)

It runs as its own background task, decoupled from kyoto event processing via an unbounded channel of ChainOps:

  • ConnectFull — filter matched, apply the downloaded full block (block_connected)
  • ConnectFiltered — filter didn't match, apply header only (filtered_block_connected with no transactions)
  • Disconnect — reorg, disconnect back to the fork point
  • Synced — kyoto reported FiltersSynced, i.e. we caught up to the network tip
  • Failed — an unrecoverable error occurred while producing ops (surfaces to sync_wallets waiters)

For applying blocks we reuse bitcoind's ChainListener, which fans each connect/disconnect out to the onchain
wallet, channel manager, chain monitor and output sweeper.

process_kyoto_events is a single sequential loop: for a matched filter it awaits the full block fetch (with
retries/timeout) inline, and for an unmatched one it awaits the header fetch inline, before moving on to the next
kyoto event — this is what ensures ops are produced in order. The block applicator then just consumes those ops
in order and applies them. As a safety net it also tracks the next expected height itself and skips any
out-of-sequence op (e.g. duplicates, or stale ops produced before a reorg); a Disconnect rewinds the expected
height to the fork point.

Besides applying blocks, the applicator is the single writer of CbfSyncState: it advances applied_tip /
synced_to_tip (which sync_wallets waits on) and persists the node's wallet-sync timestamps once the tip is
reached. When the native kyoto fee source is used, it also opportunistically caches the coinbase-derived fee rate
of every full block it applies, so the fee estimator doesn't have to download those blocks again.

There is room for improvement: we could download full blocks in parallel (e.g. using
https://docs.rs/futures/latest/futures/stream/struct.FuturesOrdered.html), which would speed up initial sync.

We could do without this additional entity, by just processing ops in place, but we stick to this design for
clarity.

Fee source

As we don't have mempool with CBF chain source, we cannot properly estimate the transaction fees.
There are three options for a fee source provider (FeeSource):

  • native kyoto estimation via extrapolation from recent blocks' fees
  • esplora
  • electrum

Using esplora or electrum just for the fees is privacy-preserving and acceptable.
Using native extrapolation is a fallback method, because it has two flaws:

  • we need to download all blocks, which defeats kyoto's bandwidth advantage
  • the estimation is still rough, as we have no mempool

Also, recently fetched blocks (when we need to download a full block anyway) are cached so they won't be downloaded
twice during a fee update.

Reorganizations

On reorgs kyoto emits events about disconnected blocks and we disconnect them accordingly.

CbfSyncState

We need that struct to distinguish between last block that kyoto has and the last block we have applied locally, these
may be different blocks. Also used to understand whether we processed everything up to kyoto's tip.

Testing

We tried to adhere to already existing tests, as they cover the vital node operation scenarios. Though some of the tests
are not meant for kyoto (for example tests related to mempool, 0-conf channels, RBF).

As we want to test exclusively the new chain source in this PR, we've added an environment variable overriding the
random chain source choice, it can be used on the following way:

LDK_TEST_CHAIN_SOURCE=cbf cargo test --test integration_tests_rust

Other available options: esplora, electrum, bitcoind-rpc, bitcoind-rest.

Also please note sometimes in test scenarios kyoto bans bitcoind because of this bug:
bitcoin/bitcoin#35354.

Previously tests assumed that the chain source and the mining node are the same entity, i.e. there is no gossip delay.
This is not the case with the kyoto node, that's why one might experience a race condition due to gossip propagation
(we do something, like mine a block with a needed transaction, then it is correctly shown in esplora/electrum/bitcoind
and we try to invoke some action, but the new block has not yet propagated to the kyoto node); the situation is
worsened by the fact that GitHub's CI runners are slower than local machines, and a simple time-based wait does not
work well to circumvent the propagation delay. That's why many functions in the tests have additional waiting for
propagation.

Tests not comptible with the compact block filter node:

  • channel_full_cycle_0conf,
  • channel_full_cycle_0conf_0reserve
  • onchain_send_receive
  • onchain_fee_bump_rbf,
  • onchain_fee_bump_rbf_respects_anchor_reserve

History

We switched to bitcoind's sync model (poll_and_update_listener vs sync_onchain_wallet and sync_lightning_wallet).

We tried to follow the patterns for already existing chain sources, more precisely we had distinct sync_onchain_wallet
and sync_lightning_wallet (dictated by transaction-based syncing sources) functions however it is suboptimal, because
we might fetch the same block twice.

@ldk-reviews-bot

ldk-reviews-bot commented Mar 15, 2026 •

Copy link
Copy Markdown

👋 I see @tankyleo was un-assigned.
If you'd like another reviewer assignment, please click here.

@febyeji
febyeji force-pushed the add-cbf-chain-source branch 5 times, most recently from 6e39d0d to 86e88ce Compare March 16, 2026 06:35
@febyeji
febyeji marked this pull request as ready for review March 16, 2026 06:53
@ldk-reviews-bot
ldk-reviews-bot requested a review from tnull March 16, 2026 06:53
@febyeji
febyeji force-pushed the add-cbf-chain-source branch from 86e88ce to 3de4777 Compare March 16, 2026 11:44
@febyeji
febyeji marked this pull request as draft March 16, 2026 11:49
@febyeji
febyeji marked this pull request as ready for review March 16, 2026 15:47
@febyeji
febyeji force-pushed the add-cbf-chain-source branch from 3de4777 to 9692ff4 Compare March 17, 2026 14:27
Comment thread src/chain/cbf.rs Outdated
Comment thread src/chain/cbf.rs Outdated
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@febyeji
febyeji marked this pull request as draft March 19, 2026 14:48
@febyeji
febyeji force-pushed the add-cbf-chain-source branch 6 times, most recently from 388d7d2 to fdf6710 Compare March 26, 2026 11:02
@tnull
tnull removed their request for review March 27, 2026 07:42
@tnull

tnull commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator

@febyeji @randomlogin Please let me know when you consider merging of the two PRs completed and this is ready for a first round of review!

Comment thread src/chain/cbf.rs Outdated
}

/// Broadcast a package of transactions via the P2P network.
pub(crate) async fn process_broadcast_package(&self, package: Vec<Transaction>) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bip157 version 0.5.0 includes a Package type that may be used here.

@tnull do I understand correctly that Vec<Transaction> is used so the traits are forward compatible for arbitrary package lengths? My crate only supports packages of size 1 and 2, as per the current Bitcoin Core policy.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hmm, well, kinda. LDK's trait uses a &[&Transaction] I believe. Generally we prefer to leave any transaction/package validation to Core via. the chain source. Given bip157 is the chain source, it should be fine to use from_vec and log an error if that fails for whatever reason.

@febyeji
febyeji force-pushed the add-cbf-chain-source branch 3 times, most recently from 8a03367 to 187b18d Compare April 24, 2026 01:46
@randomlogin

Copy link
Copy Markdown
Contributor

I've started to test it in the wild (on mutinynet with ldk-server) and encountered this problem:

3. Different listener heights: Replaying from the lowest height sends old blocks to listeners already ahead, triggering a chain-order assertion. Each listener needs synchronization from its own tip.

Working on that.

I'll update the PR with new commits with all the suggestions.

@randomlogin
randomlogin force-pushed the add-cbf-chain-source branch 4 times, most recently from d394d66 to 10e5c05 Compare October 13, 2026 20:56
randomlogin and others added 11 commits October 27, 2026 14:15
* Add CBF chain source stubs for starting

Add stub methods/functions, add basic build and start of the CBF chain
source as well as basic struct containing the fields which undoubtedtly
are needed.

* Add waiting for gossip propagation in tests

Previously tests assumed that the chain source of the lightning node and
is node which mines. This is not the case with CBF chain source which
needs to wait until after mining a new block a new tips propagates to
it.

`wait_for_block` is made to return a new height and a new function
`wait_for_node_tip` is added which waits until the given height is
processed (returned via `status.best_block` ) on a given node.

* Populate revealed spks for CBF

Ask wallet for revealed spks, register them. Implement `Listen` trait
ans add register_script method as well as implementation of registered
scripts/outputs.

---------

Co-authored-by: Yeji Han <yeji.hvn@gmail.com>
- Implement CBF requester shutdown and mark runtime status stopped on clean exit.
- Route generic chain source stop calls to the CBF backend.

AI-assisted-by: OpenAI Codex
- Stop runtime-dependent chain sources before waiting for non-cancellable background tasks.

- Allow the CBF requester shutdown to unblock the node run loop during Node::stop.
- Check CBF runtime status before publishing a rebuilt requester after restart backoff.

- Shut down the newly built requester and exit the restart loop if stop() ran during the backoff.
* Add CBF chain source stubs for starting
* Implement `process_kyoto_events` and `ChainOp`

Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
* Add CBF chain source stubs for starting
* Add waiting for gossip propagation in tests
* Populate revealed spks for CBF
* Implement `process_kyoto_events` and `ChainOp`

Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
Also added env var for the CBF tests, also waiting for tx gossip for
broadcast in some of the tests.
* 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.
randomlogin and others added 4 commits October 27, 2026 14:25
The fee-rate cache update moved into a free function that takes `network`
directly, so the client no longer reads its `config` field. This tripped
`dead_code` under `-D warnings` in CI. Remove the field and the argument
threaded through `start`/`new`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Node might go offline on a reorgnaized block, which would make stall
syncing after a restart.

This commits adds a preprocessing step which runs once before the events
from kyoto are processed.

During the preprocessing step we check whether we have forked and
disconnect blocks accordingly.

Still there might be a problem if several of chain monitors diverged and
live on different forks, but other chain sources (bitcoind) ignore that
and that perhaps requires a fix upstream.
randomlogin and others added 2 commits October 27, 2026 15:55
A CBF node has no trusted node to ask for the chain tip, so a wallet
created for the first time started from genesis and had to download
every filter on the way up.

On a first start, run a short-lived headers-only kyoto node up to the
tip, validate the best header through the same helper the bitcoind
sources use, and walk back `REORG_SAFETY_BLOCKS` from it. The wallet and
the channel manager begin from that block instead of genesis. The walk
back is needed because kyoto cannot report a reorg at or below its start
checkpoint, and on the first start `resume_checkpoint` has nothing lower
to step to.

`ChainSource::fetch_stable_tip` is only called by the builder when the
CBF source is configured and no tip is known yet.

Also move to bip157 0.8.0: `Client::warn_rx` is now a bounded receiver,
and `data_dir` no longer exists, so the unused call is removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Upstream now syncs Lightning state before peers or event processing
start, going through `ChainSource::initialize_wallets` for every backend.
The CBF source has no separate Lightning sync: `ChainSource::start`
already spawns the kyoto node, which feeds all listeners. Hold the node
start until the CBF source has caught up to the network tip and applied
its blocks, via `wait_until_synced`.

This replaces the `todo!()` placeholder left while rebasing onto
upstream.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
randomlogin and others added 2 commits October 27, 2026 18:17
CBF has no mempool, so the wallet never sees a transaction it has just
broadcast until a block confirms it. Until then BDK's coin selection
still treats the inputs as spendable, and a second send could
double-spend them.

On CBF, apply the transaction as unconfirmed ourselves when we send it
(`send_to_address`), replace it (`bump_fee_rbf`, which also evicts the
replaced transaction) or fund a channel with it. The funding case runs
from the event handler after the channel facts are recorded, so the
payment this creates is classified. This replaces the earlier approach
that hooked into the now-removed broadcast-time classification;
transactions produced by LDK itself (closes, sweeps, anchors) are not
applied this way.

CBF also has no mempool to tell us a transaction was evicted. Fall back
to a stale-broadcast deadline of 336h, matching Bitcoin Core's mempool
expiry, for outbound non-funding payments, and mark a dropped payment
failed instead of re-creating it as pending. `update_payment_store`
takes a reference time for this: the connecting block's own time rather
than the wall clock, so replaying many blocks after being offline does
not skew the comparison. Both behaviours apply to CBF only.

`apply_mempool_txs` becomes available under `chain-cbf` as well as
`chain-bitcoind`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`chain-cbf` pulled in both `chain-esplora` and `chain-electrum` only
because the optional external fee sources need their clients. Make each
source depend on its own feature instead, so CBF can be built with
either one, both or neither (in which case fee rates are derived from
recent blocks).

`CbfFeeSourceConfig::Esplora` and `::Electrum`, the matching `FeeSource`
variants and the code that uses them are gated on `chain-esplora` and
`chain-electrum` respectively. The "at least one chain source feature"
check now also counts `chain-cbf`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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.

5 participants