Skip to content

BIP157/158: compact block filters - #10

Open
xanimo wants to merge 184 commits into
dogecoinfoundation:masterfrom
xanimo:backport/bip157-158-onmaster3
Open

BIP157/158: compact block filters#10
xanimo wants to merge 184 commits into
dogecoinfoundation:masterfrom
xanimo:backport/bip157-158-onmaster3

Conversation

@xanimo

@xanimo xanimo commented Jul 27, 2026

Copy link
Copy Markdown
Member

BIP157/158: compact block filters

Backports client-side block filtering (BIP158 filters + BIP157 P2P transport)
onto master. This lets light clients sync privately by downloading compact
per-block filters and fetching only the blocks that match their wallet, instead
of leaking addresses to a server (BIP37 bloom) or trusting a third party.

This is the complete backport as a single PR. Because 164 commits is a lot to
review at once, the series is organized into twelve per-topic branches
(bip157-pr01bip157-pr12, linked below) as a suggested review roadmap: read
them in order — each is small, self-contained, and a strict superset of the one
before. This PR
(backport/bip157-158-onmaster3)
is their sum, and is what CI and the end-to-end testing below run against.


What's included

  • BIP158 Golomb-Rice compact filter construction and serialization (basic filter type)
  • Block filter index with on-disk filter + filter-header storage, built over the whole chain
  • BIP157 P2P layer: getcfilters/cfilter, getcfheaders/cfheaders, getcfcheckpt/cfcheckpt, gated behind the NODE_COMPACT_FILTERS service bit
  • RPCs: getblockfilter, getindexinfo
  • Config: -blockfilterindex (build the index), -peerblockfilters (serve it to peers)
  • Supporting refactors backported as their own branches: generic BaseIndex, txindex on that base, FlatFileSeq, LookupBlockIndex, the validation-interface rework, wallet-db abstraction, and per-txout chainstate

Review roadmap (12 per-topic branches)

# Branch Scope
01 bip157-pr01-serialization serialization primitives
02 bip157-pr02-per-txout per-txout chainstate
03 bip157-pr03-bip158 BIP158 filter core
04 bip157-pr04-validationinterface validation-interface refactor
05 bip157-pr05-wallet wallet-db abstraction
06 bip157-pr06-lookupblockindex LookupBlockIndex
07 bip157-pr07-txindex txindex on generic base
08 bip157-pr08-baseindex generic BaseIndex
09 bip157-pr09-flatfileseq FlatFileSeq
10 bip157-pr10-blockfilterindex block filter index
11 bip157-pr11-bip157 BIP157 P2P net messages
12 bip157-pr12-dogecoin Dogecoin integration + cfcheckpt response cache + P2P tests

Each branch is a strict superset of the one below it; 164 commits total.


Upstream fidelity & provenance

  • Backported from bitcoin/bitcoin. Every cherry-picked commit carries a
    Cherry-picked from: <sha> trailer that resolves to a canonical upstream commit
    (142 in total). Dogecoin-native commits carry no trailer.
  • The index base tracks upstream v0.19 (Commit/Rewind, null-locator guard);
    no non-upstream index behavior is introduced.
  • Where a pick had to be adapted to this tree, the divergence is documented inline
    in a [dogecoin: …] note rather than left silent.

Dogecoin-specific adaptations (each documented in-commit):

  • fs:: wrapper — 2016-era wallet picks are rewired from boost::filesystem
    to this tree's fs:: alias (Dogecoin migrated the db layer in a2caaf1f).
  • Boost ≥ 1.74boost::bind placeholders are qualified as
    boost::placeholders::_N and <boost/bind/bind.hpp> is included, matching
    Dogecoin's own fix (41406bfa, PR Build on recent Linux dogecoin/dogecoin#1655). Without this the backport regresses
    the build on modern system Boost.
  • Height-aware consensus params, and the scrypt PoW-skip on ReadBlockFromDisk
    when serving peers, are preserved throughout.

New surface

Kind Added
Config -blockfilterindex, -peerblockfilters
RPC getblockfilter, getindexinfo
P2P messages getcfilters/cfilter, getcfheaders/cfheaders, getcfcheckpt/cfcheckpt
Service bit NODE_COMPACT_FILTERS

Serving filters to peers is off by default; -peerblockfilters requires
-blockfilterindex.


Testing

  • Unit: full test_dogecoin suite passes (incl. the BIP158 filter vectors).
  • Functional: qa/pull-tester suite — 71/71 pass, 0 fail, including
    getblockfilter.py, getindexinfo.py, and p2p-blockfilters.py (extended with
    cross-reorg cfcheckpt coverage).
  • Real mainnet build: the block filter index was rebuilt from scratch against
    a full mainnet datadir and synced to the chain tip (height 6,307,111);
    sampled filters are valid across the whole range (genesis → tip). The full-range
    build completes with no crash, assert, or corruption.
  • Cross-toolchain: compiles cleanly against both the depends Boost (1.63) and
    system Boost (1.74).
On the cfcheckpt reorg test

The reorg coverage in p2p-blockfilters.py passes, but note what it proves: since
PrepareBlockFilterRequest() gates on chainActive.Contains(stop_index), a served
request always has its stop block on the active chain, so the checkpoint walk is
correct by construction (it uses GetAncestor(), anchored to the stop block, lock-
free, and cannot observe a mid-loop reorg). The test's value is its anti-vacuity
guard — it asserts the reorg genuinely moved a checkpoint — so the cache-staleness
path is exercised against a real chain reorganization rather than a no-op.


Notes

  • pr01 ⊂ pr02 ⊂ … ⊂ pr12 is a single consistent, GPG-signed lineage; the
    combined branch equals pr12.
  • CI depends fetches can flake while depends.dogecoincore.org's TLS certificate
    is expired; this clears once the cert is renewed. Environmental, not code.

laanwj and others added 11 commits July 23, 2026 11:24
This adds the listening address on which incoming connections were received to the
CNode and CNodeStats structures.

The address is reported in `getpeerinfo`.

This can be useful for distinguishing connections received on different listening ports
(e.g. when using a different listening port for Tor hidden service connections)
or different networks.

Cherry-picked from: a7e3c28
We don't normally use ReadVarInt from untrusted inputs, but we might
 see this in the case of corruption.

This is exposed in test_bitcoin_fuzzy.

Cherry-picked from: 45f0961
Before this commit:

  for (std::map<T1, T2>::iterator x = y.begin(); x != y.end(); ++x) {
  }

After this commit:

  for (auto& x : y) {
  }

Cherry-picked from: 680bc2c
This makes ConnectTip responsible for the ConnectTrace instead
of splitting the logic between ActivateBestChainStep and ConnectTip

Cherry-picked from: 822000c
This makes a later change to move it all into one per-block callback
simpler.

Cherry-picked from: f404334
@xanimo
xanimo force-pushed the backport/bip157-158-onmaster3 branch 7 times, most recently from 4d2e94c to 1467cc0 Compare August 2, 2026 04:30
TheBlueMatt and others added 12 commits August 1, 2026 23:11
This simplifies fixing the wallet-returns-stale-info issue as we
can now hold cs_wallet across an entire block instead of only
per-tx (though we only actually do so in the next commit).

This change also removes the NOT_IN_BLOCK constant in favor of only
passing the CBlockIndex* parameter to SyncTransactions when a new
block is being connected, instead of also when a block is being
disconnected.

This change adds a parameter to BlockConnectedDisconnected which
lists the transactions which were removed from mempool due to
confliction as a result of this operation. While its somewhat of a
shame to make block-validation-logic generate a list of mempool
changes to be included in its generated callbacks, fixing this isnt
too hard.

Further in this change-set, CValidationInterface starts listening
to mempool directly, placing it in the middle and giving it a bit
of logic to know how to route notifications from block-validation,
mempool, etc (though not listening for conflicted-removals yet).

Cherry-picked from: 461e49f

[dogecoin: the boost::bind placeholders here are qualified as
boost::placeholders::_N for Boost >= 1.73, matching this tree's
convention; see dogecoin dogecoin#1655.]
This simplifies fixing the wallet-returns-stale-info issue as we
now hold cs_wallet across an entire block instead of only per-tx.

Cherry-picked from: e6d5e6c
Cherry-picked from: 91f1e6c

[dogecoin: the boost::bind placeholders here are qualified as
boost::placeholders::_N for Boost >= 1.73, matching this tree's
convention; see dogecoin dogecoin#1655.]
Try to hide CDB/bitdb behinde CWalletDB.
Prepare for full wallet database abstraction.

Cherry-picked from: 7184e25

[dogecoin: use the fs:: wrapper instead of boost::filesystem, matching the
fs migration (a2caaf1, 37909dd). basename()/extension() become
path::stem()/extension(), which were removed from Boost in 1.85.]
This function has been unused ever since the RPC tests no longer use
`bitcoin-cli`.

Cherry-picked from: 99fecf8
xanimo and others added 28 commits August 1, 2026 23:40
Fixes that belong with the blockfilterindex change itself rather than
two branches later:

- Use CDiskBlockPos rather than Bitcoin's FlatFilePos in the
  ReadFilterFromDisk/WriteFilterToDisk signatures and definitions.
- Include <util.h>; this tree has no util/system.h.
- Guard the global filter index map with a critical section.
- Port blockfilter_index_tests.cpp to this tree's APIs: pass consensus
  params by height, take CDiskBlockPos + hash in UndoReadFromDisk,
  add the fMineWitnessTx argument to CreateNewBlock, and construct
  BlockFilter explicitly.
…wn call

- Use CDiskBlockPos, not Bitcoin's FlatFilePos, in the on-disk
  read/write paths; include <util.h> (this tree has no util/system.h).
- init.cpp: remove an interrupt_all()/join_all() pair wrongly added to
  Shutdown(); this tree interrupts threads via Interrupt() and never
  calls join_all() there (matches the baseline and green integration).

Relocated from the later catch-all backport-fixup commit to the branch
that introduces blockfilterindex.
getblockfilter constructed its placeholder BlockFilter from an empty
byte vector before LookupFilter() overwrites it. Decoding an empty GCS
filter throws "VectorReader::read(): end of data", failing the RPC.
Use a single 0x00 byte (a valid zero-element GCS filter) as the
placeholder.

Relocated from the later catch-all backport-fixup commit to the branch
that introduces the getblockfilter RPC.
GetFilterType() returned BASIC unconditionally: the ternary tested for
BASIC and returned BASIC either way. That silently disabled both guards
built on it.

WriteBlock()'s

    if (GetFilterType() != BlockFilterType::BASIC) return error(...)

became unreachable, and WriteFilterToDisk()'s

    assert(filter.GetFilterType() == GetFilterType())

became vacuous. A filter of an unsupported type would have been written
into the basic index rather than rejected -- the opposite of what both
checks were added for.

This is not upstream behaviour; it was introduced in this series.
…own order

With BlockFilter default-constructible again, LookupFilterRange() and the
getblockfilter RPC no longer need to fabricate a one-byte filter just to
have an object to read into. The placeholder was load-bearing only in
that it had to survive GCSFilter's constructor, which made it look
meaningful when it was not.

Also documents the shutdown ordering around DestroyAllBlockFilterIndexes().
GetBlockFilterIndex() returns a raw pointer after dropping
g_cs_block_filter_indexes, so the lock protects the map but not the
lifetime of the returned index. That is safe today only because
g_connman->Stop() joins threadMessageHandler before the indexes are
destroyed. Nothing recorded that dependency, so a later reordering of
AppShutdown could reintroduce a use-after-free with no obvious cause.
files.md gained a row for indexes/txindex/ but not for the block filter
index this series also creates, so a single change left the file
internally inconsistent.

The index writes two kinds of file under indexes/blockfilter/<type>/:
a LevelDB database in db/, and the filters themselves in fltr?????.dat
flat files managed by FlatFileSeq. Both are listed, since operators
sizing a data directory need to know the flat files exist -- they hold
the bulk of the data, not the LevelDB index.
If -peerblockfilters is configured, signal the NODE_COMPACT_FILTERS service
bit to indicate that we are able to serve compact block filters, headers
and checkpoints.

Cherry-picked from: 132b30d
Cache block filter headers at heights of multiples of 1000 in memory.

Block filter headers at height 1000x are checkpointed, and will be the
most frequently requested. Cache them in memory to avoid costly disk
reads.

Cherry-picked from: 0187d4c
Port upstream test/functional/rpc_misc.py getindexinfo coverage to
qa/rpc-tests/getindexinfo.py for the legacy rpc-test harness.

Cherry-picked from: c447b09
The compact block filter headers cache added here declares its lock as
Mutex, which this tree does not have. Use CCriticalSection and include
<sync.h> for it and the GUARDED_BY annotation.

Also align qa/rpc-tests/getindexinfo.py with the RPC as implemented on
this branch.
- Remove duplicate net-processing constant definitions re-added by the
  backport on top of the ones this tree already defines.
- PrepareBlockFilterRequest: gate the stop block on
  chainActive.Contains() rather than BlockRequestAllowed().
- protocol.cpp: drop the NODE_NETWORK_LIMITED service-flag case for a
  flag this tree does not define.
- init.cpp: signal NODE_COMPACT_FILTERS via std::find over the enabled
  filter types (this tree's vector has no count()).

Relocated from the later catch-all backport-fixup commit to the BIP157
net-layer branch.
The getindexinfo command table entry references &getindexinfo, but the
function is defined further down in the file. Add a forward declaration
so misc.cpp compiles.

Relocated from the later catch-all backport-fixup commit to the branch
that introduces the getindexinfo RPC.
The 2000-entry cap and its comment came from upstream, where a 1000-block
checkpoint interval and a 10-minute target put 2,000,000 blocks somewhere
around 2047. Dogecoin's 1-minute target is already past 6,300,000, so the
cap is roughly a third of what the chain needs.

The cache only inserts while size() < CF_HEADERS_CACHE_MAX_SZ and never
evicts, so the practical effect is worse than a cache that is merely too
small: it fills with the oldest 2000 checkpoints and then permanently
refuses the ~4,300 near the tip, which are the ones peers actually ask
for. Every getcfcheckpt at the tip then misses and walks LevelDB for
thousands of headers with m_cs_headers_cache held, serializing every
other peer behind it.

At 64 bytes per entry, 16000 entries is about 1 MiB.
-blockfilterindex has a help entry but -peerblockfilters had none, so the
flag that actually exposes filters to the network was invisible in
dogecoind -help and discoverable only by reading init.cpp.

Also replaces the bare false at the GetBoolArg call site with
DEFAULT_PEERBLOCKFILTERS, so the documented default and the applied
default cannot drift apart, matching how DEFAULT_BLOCKFILTERINDEX and
DEFAULT_TXINDEX are handled.
Records why PrepareBlockFilterRequest() diverges from upstream. Requiring
the stop block to be on the active chain, rather than using
BlockRequestAllowed(), is what makes keying the cfcheckpt cache on
stop_hash alone sound.

It is a peer-facing policy change, not just an API adaptation: a client
asking for a tip that was reorged out a moment earlier is disconnected
rather than served, which is the race upstream's ~1 month window exists
to absorb.
serviceFlagToStr() deliberately omits a default arm so the compiler warns
when a service flag has no case -- upstream's comment says as much. It
arrived here with cases for upstream's flags only, and Dogecoin also
defines NODE_XTHIN (1 << 4), so the switch warned under -Wswitch from the
moment it landed.

Adding the case silences the warning the intended way rather than by
adding a default arm, which would disable the check for every flag added
later.
Exercise the BIP 157 wire protocol (getcfilters, getcfheaders,
getcfcheckpt) over the P2P framework.  Covers NODE_COMPACT_FILTERS
advertisement, correct cfilter / cfheaders / cfcheckpt responses and
basic checkpoint-interval validation.
Test that the node disconnects peers sending malformed or oversized
getcfilters / getcfheaders / getcfcheckpt requests:

  - unsupported filter type (all three message types)
  - unknown / off-chain stop hash
  - start_height > stop_height
  - range >= MAX_GETCFILTERS_SIZE  (1000)
  - range >= MAX_GETCFHEADERS_SIZE (2000)
p2p-blockfilters.py and getblockfilter.py contained no references to
invalidateblock, reconsiderblock, or reorgs, which is why the header-walk
bug in ProcessGetCFCheckPt went unnoticed.

Adds four cases:

- cfcheckpt spanning two checkpoint intervals.  The existing case stops at
  height 1001 and yields a single header, so it cannot distinguish a
  correct walk from one that only ever reads the first entry.
- a reorg beneath the second checkpoint, with a transaction mined onto the
  replacement chain so the checkpoint header is guaranteed to move rather
  than differing only by an incidentally distinct coinbase.  The test
  asserts the first checkpoint is unchanged and the second has moved, so
  that a reorg which failed to alter anything fails loudly instead of
  passing vacuously.
- a stop hash reorged off the active chain, which must be rejected rather
  than served from the response cache or a stale index.
- cfcheckpt and getcfilters after the reorg, checked against the RPC values
  for the current chain.

Note on what this does not cover: the mixed-chain case in the pre-fix
header walk -- where cs_main was released between loop iterations and the
vector could be assembled from two chains -- is a race and cannot be
provoked deterministically from a functional test.  With the walk anchored
to stop_index it is impossible by construction, which is the reason to fix
it there rather than to test around it.

Requires the wallet for sendtoaddress; if these tests need to run against
a --disable-wallet build, the replacement chain will need another way to
guarantee a distinguishing filter.
Cache the computed checkpoint header vector in net_processing keyed by
(filter_type, stop_hash), so repeated getcfcheckpt requests for the same
stop block are served without re-reading filter headers from the index.
At current chain height each miss costs roughly 6,200 index reads.

The cache needs no reorg invalidation: PrepareBlockFilterRequest() checks
chainActive.Contains(stop_index) before the cache is consulted, so a hit
implies the stop block is still on the active chain, which pins every one
of its ancestors.

The header walk itself deliberately keeps GetAncestor() rather than
chainActive[height].  It is anchored to the requested stop block instead
of the moving tip, so it needs no lock and cannot observe a reorg partway
through the loop.

This is Dogecoin-specific; there is no upstream equivalent.  The index-level
header cache from Bitcoin Core is a separate commit in this stack.
Reorder artifact: PR #9725 (37060fb038) is stacked before PR #8574 in this
linear-faithful arrangement. Upstream applies #8574 (which adds the include)
before #9725 (which removes it), netting to removed. With #9725 first, the
removal is a no-op and #8574 re-adds the now-unused include. This cleanup
restores the byte-identical final tree.
msg_cfilter.deserialize() read block_hash before filter_type, mirroring
the same transposition the C++ serializer had. Because both sides were
wrong in the same way, the test round-tripped against our own node and
passed, which is why the wire format stayed broken through every run of
this test.

With the serializer corrected, this parser is now an independent check:
reverting it makes the test fail on 'assert message.filter_type == 0',
which is exactly the failure a conforming BIP 157 client would hit
against a node with the old framing.
The cache keeps one entry per filter type, so two peers alternating
between different stop hashes evict each other and every request pays the
full ancestor walk. That is a deliberate tradeoff rather than an
oversight, but nothing said so.

Keying on stop_hash alone is sound because PrepareBlockFilterRequest
requires the stop block to be on the active chain, which pins the
ancestor set. The same restriction is what makes the thrash case rare in
practice: peers converge on the tip and end up sharing the entry.
The series adds a user-visible feature (compact block filters) and a
downgrade-breaking chainstate change, and neither was documented anywhere
under doc/. A grep for blockfilterindex, peerblockfilters, getblockfilter,
getindexinfo, hash_serialized_2 or per-txout across doc/ hit only files.md.

Every statement here is checked against this branch rather than carried
over from upstream's notes: the option names and their defaults, the
NODE_COMPACT_FILTERS bit value, the six P2P message names, the prune and
-blockfilterindex init guards, the on-disk index layout, the automatic
CCoinsViewDB::Upgrade() call, and the gettxoutsetinfo field rename.

Also covers the user-visible changes that ride along with the series:
getpeerinfo's new addrbind field; the wallet RPCs' call to
BlockUntilSyncedToCurrentChain(); and the three fields the per-txout
chainstate removes -- gettxout's version, gettxoutsetinfo's
bytes_serialized, and the REST getutxos txvers field.

Two of those deserve spelling out for operators rather than only for
developers. The wallet change alters RPC latency and consistency
semantics, so a wallet call can now block on the validation queue
draining; that surfaces as a wallet RPC intermittently taking longer just
after a block arrives, which is hard to attribute without knowing it was
deliberate. And the REST binary format keeps the version field's width
while always writing zero, so parsing still succeeds and a consumer simply
reads 0 where it read a real version -- a missing JSON key gets noticed, a
plausible zero does not.
@xanimo
xanimo force-pushed the backport/bip157-158-onmaster3 branch from eda3229 to 1ac165a Compare August 2, 2026 06:50
Ports the assertions this test was missing relative to upstream's
p2p_blockfilters.py. The upstream commits themselves (2308385,
9e36067, 5308c97, f5c003d) target test/functional/ and cannot be
cherry-picked onto this tree's qa/rpc-tests harness, so the checks are
carried over rather than the files.

Two gaps mattered.

The cfheaders response was only compared against filter hashes derived
from our own getblockfilter output, which checks self-consistency: if the
node computed a filter header wrongly it would report it wrongly in both
places and the test would still pass. compute_last_header() applies BIP
157's chaining rule directly -- header_n = SHA256d(filter_hash_n ||
header_n-1) -- and checks that chaining the returned hashes onto
prev_header reproduces the header for the stop block. Verified
non-vacuous: reversing the hash order fails the assertion.

Nothing exercised a node running -blockfilterindex without
-peerblockfilters. That node must not advertise NODE_COMPACT_FILTERS,
over the wire or in getnetworkinfo, and must disconnect peers that send
filter requests anyway. A second node covers that, including all three
request types, since each is gated separately in
PrepareBlockFilterRequest().
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.