Skip to content

feat: Bitswap improvements for Kubo compatibility#1321

Open
sumanjeet0012 wants to merge 25 commits into
libp2p:mainfrom
sumanjeet0012:improvement/bitswap
Open

feat: Bitswap improvements for Kubo compatibility#1321
sumanjeet0012 wants to merge 25 commits into
libp2p:mainfrom
sumanjeet0012:improvement/bitswap

Conversation

@sumanjeet0012
Copy link
Copy Markdown
Contributor

Bitswap + DHT improvements for Kubo compatibility

What was wrong?

Issue: py-libp2p's Bitswap file operations were not compatible with Kubo (Go-IPFS)
and the broader IPFS network. Files added by py-libp2p could not be fetched by Kubo
nodes, and vice versa. Several root causes were identified:

1. Wrong leaf block encoding (CODEC_RAW instead of dag-pb + UnixFS)

MerkleDag.add_file() and add_bytes() stored leaf chunks as raw blocks:

# BEFORE — wrong
chunk_cid = compute_cid_v1(chunk_data, codec=CODEC_RAW)

Kubo wraps every leaf in UnixFS Data(type=File, data=chunk) inside a dag-pb PBNode.
Using CODEC_RAW produced completely different CIDs for the same content.

2. Flat DAG structure (no balanced layout)

create_file_node() put all chunks as direct links on the root node — a flat
1-level tree. Kubo uses a balanced tree with a maximum of 174 links per node
(balanced.Layout). For a 100 MB file at 63 KB chunks (~1,626 chunks), the root
block was megabytes in size, exceeded MAX_BLOCK_SIZE, and produced wrong CIDs.

3. Wrong DAG-PB wire encoding (field ordering)

encode_dag_pb() used PBNode.SerializeToString() which emits Data (field 1)
before Links (field 2). The DAG-PB spec
requires Links before Data for canonical encoding. Wrong byte order → wrong CID
for every node, even when leaf encoding was correct.

4. No persistent block storage

MemoryBlockStore was the only BlockStore implementation. All fetched blocks were
lost when the process exited — not usable for any production or long-running node.

5. No transparent caching layer

MerkleDag called bitswap.get_block() / bitswap.add_block() directly with no
service layer. Network-fetched blocks were not auto-cached locally, and newly stored
blocks did not announce to waiting peers.

6. No stream input support

add_file() only accepted a file path string. There was no way to add a BytesIO,
GzipFile, network pipe, or any io.IOBase stream without loading everything into
memory first.

7. Magic integers in Bitswap message construction

create_wantlist_entry(cid, want_type=1) used raw integers with no type safety.
No WantType enum, no BlockPresence type, no typed BitswapMessage class.

8. DHT record signing/verification not compatible with Kubo

DHT value records lacked proper signing and verification, causing incompatibility
with Kubo's DHT implementation.


How was it fixed?

Bitswap: Kubo CID compatibility

New create_leaf_node(data) in dag_pb.py:
Wraps each chunk in UnixFS Data(type=File) + PBNode — matches Kubo's
RawLeaves=false default. Leaf CIDs are now byte-identical to Kubo.

def create_leaf_node(data: bytes) -> bytes:
    unixfs_data = UnixFSData(type="file", data=data, filesize=len(data))
    return encode_dag_pb([], unixfs_data)   # dag-pb, not raw

New balanced_layout(leaves) in dag_pb.py:
Groups leaves into batches of 174, builds a tree level by level — exactly matching
Go's balanced.Layout. Files of any size now produce correct CIDs.

Fixed encode_dag_pb() in dag_pb.py:
Manually constructs wire bytes with Links (field 2, tag 0x12) before Data
(field 1, tag 0x0a) — DAG-PB canonical ordering.

Updated add_file() and add_bytes() in dag.py:
Both methods now use create_leaf_node() + balanced_layout() instead of
CODEC_RAW + create_file_node().

Bitswap: batch fetching

Enhanced get_blocks_batch() in client.py:
Sends all CIDs in a single wantlist message per batch, waits for all responses on
the same stream. Avoids opening hundreds of individual streams (which caused Kubo
to send GO_AWAY).

New: FilesystemBlockStore

New FilesystemBlockStore in block_store.py:
Stores each block as a file at <base>/<cid[:2]>/<cid[2:]>. Uses
trio.to_thread.run_sync for non-blocking disk I/O. Drop-in replacement for
MemoryBlockStore — blocks now survive process restarts.

New: BlockService

New block_service.py:
Transparent local→network fallback layer between MerkleDag and BitswapClient:

  • get_block(): checks local store first (free), falls back to network, auto-caches result
  • put_block(): stores locally + calls bitswap.add_block() to announce to waiting peers
  • get_blocks_batch(): local hits skip the network entirely

MerkleDag now accepts an optional block_service: BlockService parameter. All block
access routes through _put_block / _get_block / _get_blocks_batch helpers.
No regression — MerkleDag(bitswap) without block_service still works unchanged.

New: add_stream() + chunk_stream()

New chunk_stream(stream: io.IOBase) in chunker.py:
Reads one chunk at a time from any io.IOBaseBytesIO, open() handles,
GzipFile, BZ2File, network streams.

New add_stream() method on MerkleDag:
Accepts any io.IOBase with constant memory usage (O(chunk_size) regardless of
file size). Produces the same CID as add_file() for the same content.

New: Wantlist / Message dataclasses

New wantlist.py with 6 typed dataclasses:

  • WantType enum (Block=0, Have=1) replaces magic integers
  • BlockPresenceType enum (Have=0, DontHave=1)
  • WantlistEntry, Wantlist — typed wantlist with add(), cancel(), contains()
  • BlockPresence — typed HAVE/DONT_HAVE response
  • BitswapMessage — full message builder with to_proto() / from_proto()

create_wantlist_entry() updated to accept WantType | int — fully backward compatible.

DHT: record signing and verification

Enhanced DHT record handling with proper signing and verification for compatibility
with Kubo's DHT implementation. Updated kademlia.proto, value_store.py,
envelope.py, peer_record.py, and added records/record.py + records/utils.py.


Files changed

File Change
libp2p/bitswap/dag_pb.py create_leaf_node(), balanced_layout(), fixed encode_dag_pb() canonical ordering
libp2p/bitswap/dag.py Updated add_file(), add_bytes(), new add_stream(), BlockService routing
libp2p/bitswap/client.py Enhanced get_blocks_batch() with single-wantlist-per-batch strategy
libp2p/bitswap/chunker.py New chunk_stream(stream: io.IOBase)
libp2p/bitswap/block_store.py New FilesystemBlockStore
libp2p/bitswap/block_service.py New fileBlockService
libp2p/bitswap/wantlist.py New file — typed dataclasses
libp2p/bitswap/messages.py create_wantlist_entry() accepts WantType | int
libp2p/bitswap/__init__.py Exports all new types
libp2p/kad_dht/ DHT signing/verification for Kubo compatibility
libp2p/records/ New filesrecord.py, utils.py
libp2p/peer/envelope.py, peer_record.py Updated for record signing
examples/bitswap/bitswap.py Updated example

Test files added

File What it tests
test_unixfs_encoding.py dag-pb leaf encoding + balanced DAG layout
test_canonical_dag_pb.py DAG-PB canonical wire ordering (Links before Data)
test_filesystem_blockstore.py FilesystemBlockStore persistence + round-trip
test_block_service.py BlockService local hit / miss / auto-cache / announce
test_io_stream.py chunk_stream() + add_stream() with BytesIO, GzipFile, file handles
test_wantlist.py WantType, Wantlist, BitswapMessage, to_proto() / from_proto()

To-Do

  • Clean up commit history
  • Add or update documentation related to these changes
  • Add entry to the release notes

Cute Animal Picture

A capybara relaxing in a hot spring, perfectly content

sumanjeet0012 and others added 12 commits April 14, 2026 15:55
…compatibility with ipfs kubo

- Added support for signed records in the DHT by introducing `make_signed_put_record` function.
- Updated `ValueStore` to create signed records when storing values.
- Enhanced `Envelope` class to handle raw payload types for peer records.
- Introduced utility functions for signing and verifying DHT records.
- Updated protobuf definitions to include author and signature fields in records.
- Improved logging and debug messages for better traceability.
…ce DAG-PB encoding

Co-authored-by: Copilot <copilot@github.com>
… layout

Co-authored-by: Copilot <copilot@github.com>
… in MerkleDag

Co-authored-by: Copilot <copilot@github.com>
…s and implement add_stream method in MerkleDag for handling io.IOBase streams

Co-authored-by: Copilot <copilot@github.com>
…improve chunk_stream documentation, and add Wantlist functionality

Co-authored-by: Copilot <copilot@github.com>
- Introduced `test_block_service.py` to validate BlockService behavior including local hits, network fetches, auto-caching, and block storage.
- Created `test_filesystem_blockstore.py` to manually test FilesystemBlockStore for basic operations, persistence, and directory structure.
- Added `test_io_stream.py` to verify io.IOBase input support with chunk_stream and MerkleDag.add_stream functionalities.
- Implemented `test_unixfs_encoding.py` to ensure add_file and add_bytes produce dag-pb leaf blocks and validate balanced layout tree structures.
- Developed `test_wantlist.py` to test Wantlist and Message dataclasses, including backward compatibility and public API exports.
- Updated type hints in `make_service` function to allow for None.
- Specified type hints for lists of bytes in block retrieval tests.
- Added assertions to check for non-null `unixfs` in various tests to ensure proper decoding of DAG PB blocks.
- Enhanced type hints for observer and subscriber peers in Gossipsub tests.
- Improved type hints for candidate lists in opportunistic grafting tests.
- Added type ignore comments for factory Meta classes to suppress type checker warnings.
- Updated import statements for ID to include type ignore comments in interop utilities.
@sumanjeet0012 sumanjeet0012 force-pushed the improvement/bitswap branch from 475086a to 6acceb2 Compare May 3, 2026 11:04
sumanjeet0012 and others added 10 commits May 3, 2026 22:41
…handling, and update tests for dag-pb leaf blocks

Co-authored-by: Copilot <copilot@github.com>
…ndling in DAG fetching

Co-authored-by: Copilot <copilot@github.com>
…leDag

Co-authored-by: Copilot <copilot@github.com>
…aching in Bitswap

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
Copy link
Copy Markdown
Contributor

@yashksaini-coder yashksaini-coder left a comment

Choose a reason for hiding this comment

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

@sumanjeet0012 Strong PR overall — the encoding root-causes are correct and well-tested (verified locally: 238/238 bitswap tests pass, wire format confirmed Links-before-Data). The PR description is exemplary.

Leaving a few comments inline. Three I'd flag as blocking before merge:

  1. Breaking API change in add_file default (wrap_with_directory=True) — old callers get a directory CID instead of a file CID for the same call. Either revert the default or call this out loudly in the newsfragment.
  2. verify_record only handles Ed25519 — silently fails for RSA/Secp256k1 peers, breaking DHT interop with non-Ed25519 nodes.
  3. DEFAULT_CHUNK_SIZE = 63 KiB - 32 doesn't match Kubo's 256 KiB default — files added by py-libp2p won't have the same root CID as ipfs add file.bin unless Kubo is told --chunker=size-65504. The PR header claims "Kubo CID compatibility"; this caveat needs to be documented.

Medium-priority items (perf and hygiene) inline. Everything else is comfortable post-merge cleanup.

Nice work on the manual DAG-PB outer envelope — limiting hand-rolling to 0x12 <varlen> <linkbytes> then 0x0a <varlen> <unixfs> while still using protobuf for the inner messages is the minimal, correct approach.

Comment thread libp2p/bitswap/dag.py
@@ -187,7 +272,7 @@ async def add_file(

dir_data = create_directory_node([(filename, cid, file_size)])
dir_cid = compute_cid_v1(dir_data, codec=CODEC_DAG_PB)
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.

Breaking API change (re: the wrap_with_directory=True default a few lines up at the parameter decl): defaulting this to True silently changes the behavior of add_file(path) for every existing caller — they'll now get a directory CID where they previously got a file CID, and fetch_file returns a (bytes, filename) tuple where it returned bytes before. This is invisible in the new tests because they all pass wrap_with_directory=False.

Suggest defaulting to False for back-compat, or version-bump and add a clear migration note to newsfragments/1321.feature.rst (currently doesn't flag this as breaking).

Comment thread libp2p/records/utils.py Outdated

"""
try:
public_key = Ed25519PublicKey.from_bytes(author_public_key)
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.

Interop bug for non-Ed25519 peers. This hard-codes Ed25519 deserialization, so DHT records signed by RSA or Secp256k1 peers will silently fail verification (caught by the try/except and returned as False, indistinguishable from a genuinely invalid signature).

Compare with libp2p/peer/envelope.py:198-216 (pub_key_from_protobuf) which dispatches on KeyType for all three types. Either dispatch the same way here, or accept a PublicKey object and let the caller deserialize.

Kubo defaults to Ed25519 today, so this won't surface in basic interop tests, but it's a real correctness gap.

Comment thread libp2p/bitswap/chunker.py
DEFAULT_CHUNK_SIZE = 63 * 1024
# 63 KB minus 32 bytes to leave room for the dag-pb leaf envelope overhead,
# ensuring wrapped blocks never exceed MAX_BLOCK_SIZE (63 * 1024).
DEFAULT_CHUNK_SIZE = 63 * 1024 - 32
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.

Mismatch with Kubo's default chunk size. Kubo's ipfs add uses size-262144 (256 KiB) by default. With this 63 KiB default, py-libp2p will produce a different root CID than ipfs add file.bin for the same content — contradicting the PR's "Kubo CID compatibility" headline.

Leaf CIDs match Kubo (because of RawLeaves=false + dag-pb wrapping), but the root over a multi-chunk file won't. Either:

  • Document this clearly in newsfragments/1321.feature.rst ("CIDs match ipfs add --chunker=size-65504"), or
  • Match Kubo's 256 KiB default and split large messages at the wire layer instead of capping the chunk size.

Comment thread libp2p/bitswap/dag.py
chunk = leaf_raw
logger.debug(f"[DAG] Leaf {idx + 1}: raw block {len(chunk)} bytes")

file_data += chunk
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.

O(n²) bytes concat. file_data += chunk over potentially thousands of leaves means each += allocates a new bytes object and copies all previous data. For a 100 MB file at 63 KB chunks (~1626 leaves), this allocates roughly 80 GB of intermediate strings.

The fix is one line — accumulate into a bytearray (or list + b"".join(parts) at the end). This is the single biggest perf win available in the PR.

Same pattern in _read_message (client.py:1017-1047) and encode_dag_pb (dag_pb.py:125-149).

await trio.to_thread.run_sync(
lambda: path.parent.mkdir(parents=True, exist_ok=True)
)
await trio.to_thread.run_sync(path.write_bytes, data)
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.

Non-atomic writes. path.write_bytes(data) writes in place. If the process crashes mid-write, the next startup finds a truncated file at a CID path. get_block will then return corrupted bytes that fail verification only if the caller checks.

Standard fix: write to path.with_suffix('.tmp'), then os.replace(tmp, path) — atomic on POSIX, durable on most filesystems.

Comment thread libp2p/bitswap/dag.py
# Ensure the result is a plain dict (not a coroutine from a mock)
if isinstance(result, dict):
return result
except Exception:
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.

Test infrastructure leaking into production code. The getattr probe + isinstance(result, dict) check + bare except Exception: pass is a workaround for MagicMock returning a coroutine object. This silently masks real failures in production.

Suggest defining a Protocol for the batch interface, typing self.bitswap with it, and removing the runtime probing entirely. Tests can then mock the protocol explicitly.

Comment thread libp2p/bitswap/client.py

# Send all CIDs in a single wantlist to the peer
if peer_id:
await self._send_wantlist_to_peer(peer_id, batch)
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.

Swallowed exception in _send_wantlist_to_peer causes hangs. That helper (further down in this file) catches all exceptions, logs "Failed to send wantlist to peer", and returns. When called from this batch path, if host.new_stream or _write_message fails for one CID in the batch, the corresponding _pending_requests[cid].wait() below will block until trio.fail_after(timeout) cancels it — adding the full timeout (default 30s) to every per-batch failure.

Fix: propagate the failure from _send_wantlist_to_peer, or event.set() with a sentinel so the waiter can fail fast.

Comment thread libp2p/bitswap/dag_pb.py
# We manually construct the wire format to enforce the correct ordering.

# Add links
result = b""
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.

Minor: same O(n²) concat pattern as fetch_file reassembly. With 174 links on an internal node and large CIDs, each result += ... allocates a fresh bytes. Trivial fix: build into a bytearray and convert to bytes at return.

Comment thread libp2p/bitswap/chunker.py
yield chunk


def chunk_stream(
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.

Nit: this new chunk_stream should also be added to the module's __all__ (further down in this file). Doesn't affect direct imports, but it breaks from chunker import * and confuses some IDE auto-import tools.

Comment thread libp2p/bitswap/dag.py
ordered_leaf_cids: list[bytes] = []

def _collect_leaves_local(cid_bytes: bytes, depth: int = 1) -> None:
"""Traverse locally-fetched blocks to collect leaf CIDs."""
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.

Minor: _collect_leaves_local is unbounded recursion. For a balanced 174-fanout DAG over a 100 MB file the depth is ~2, so this never trips Python's default 1000-frame limit in practice. But a maliciously crafted DAG (e.g. a chain of single-link nodes) would crash with RecursionError. Convert to an explicit stack if you want to harden this.

@Winter-Soren
Copy link
Copy Markdown
Contributor

PR #1321 AI Review

1. Summary of Changes

  • This PR introduces a large Bitswap/DAG feature set: batch wantlist fetching, BlockService, FilesystemBlockStore, stream ingestion (chunk_stream, add_stream), provider discovery manager, and Kubo-oriented DAG-PB/CID interoperability updates.
  • It also includes Kademlia record-signing changes, transport/test refactors, and multiple docs/newsfragment updates.
  • Related issue references present in branch history/newsfragments include #1321 and several prior issues merged in this branch lineage.
  • Affected architecture areas: libp2p.bitswap, libp2p.kad_dht, record/signature handling (libp2p.records), plus tests and docs.
  • Breaking change note is present in existing release notes/newsfragments around legacy CID helper migration (not this review's primary blocker, but user-facing and important).

2. Strengths

  • Strong feature depth for Bitswap interoperability and performance, with substantial test additions in tests/core/bitswap/.
  • Clear separation of responsibilities with new components (ProviderQueryManager, BlockService, Wantlist).
  • Good effort toward typed APIs and user-facing docs/newsfragments.
  • newsfragments/1321.feature.rst is present and formatted as expected.

3. Issues Found

Critical

  • None found with high confidence.

Major

  • File: libp2p/bitswap/provider_query.py

  • Line(s): Provider query path inside _query_single

  • Issue: ProviderQueryManager claims DHT provider discovery but calls self.dht.provider_store.get_providers(cid_bytes), which is a local provider-store read, not a network provider lookup. This can prevent remote provider discovery and silently degrade to fallback behavior.

  • Suggestion: Replace local-store read with the actual async DHT provider lookup path (KadDHT.find_providers(...)/equivalent network query API), then cache those remote results.

  • File: libp2p/kad_dht/value_store.py

  • Line(s): _store_at_peer

  • Issue: put() creates signed records (make_signed_put_record), but outbound PUT_VALUE RPC in _store_at_peer sets only record.key and record.value; signature/author fields are not propagated. This can break signed-record interoperability and weaken authenticity guarantees.

  • Suggestion: Build outbound RPC records from the signed record object (including signature/author fields) and add tests asserting these fields are present in serialized outbound Message.record.

Minor

  • No additional minor issues reported; review focused on behavior and correctness over cosmetic changes.

4. Security Review

  • Risk: Signed DHT record metadata not transmitted in outbound PUT_VALUE flow.
  • Impact: Medium (authenticity/interoperability degradation; possible rejection by stricter peers).
  • Mitigation: Ensure author and signature fields are serialized in outbound record messages and covered by unit/integration tests.

5. Documentation and Examples

  • Docs/newsfragment coverage is generally good for this PR scope.
  • However, provider discovery behavior documentation should match implementation: if discovery is advertised as DHT-network based, implementation/tests must prove network query behavior rather than local cache-only reads.

6. Newsfragment Requirement

  • Required newsfragment found: newsfragments/1321.feature.rst.
  • No blocker on newsfragment presence for PR #1321.

7. Tests and Validation

  • New tests are substantial, especially under tests/core/bitswap/.
  • Major test gap: current provider-query tests mostly mock local provider-store behavior and do not verify real DHT network provider lookup path.
  • Major test gap: no assertion that outbound PUT_VALUE includes signed record fields (author, signature).
  • Local validation results:
    • make pr failed in this environment due ruff issues under references/rust-libp2p-references/.../fix-unreachable-pub.py.
    • make linux-docs built docs but ended with environment-specific failure (xdg-open: No such file or directory).

8. Recommendations for Improvement

  • Switch provider discovery from local provider_store.get_providers reads to true DHT network querying and keep cache layer as an optimization.
  • Propagate full signed record fields in outbound DHT PUT_VALUE.
  • Add integration test proving provider discovery finds remote providers not already in local store.
  • Add unit test around _store_at_peer serialization ensuring signed fields are present.
  • Re-run CI checks in a clean Linux CI environment to separate repository issues from local tooling constraints.

9. Questions for the Author

  • Was ProviderQueryManager intentionally designed to read only local provider store state, or should it perform network DHT lookup?
  • Should signed PUT_VALUE interoperability with Kubo peers require transmitting author/signature on every outbound store operation?
  • Do you have an integration test scenario where a provider is discovered only via remote DHT query (not pre-populated local store)?

10. Overall Assessment

  • Quality Rating: Needs Work
  • Security Impact: Low to Medium
  • Merge Readiness: Needs fixes
  • Confidence: High

sumanjeet0012 and others added 3 commits May 6, 2026 23:19
… lookups and always send signed records.

Co-authored-by: Copilot <copilot@github.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.

4 participants