Blob Transport
What a blob is
Section titled “What a blob is”A blob is an opaque, content-addressed byte string. It is identified by a
BlobId — a 32-byte hash derived from the blob’s content — and it is displayed
as a base58 string. Because the identifier is a hash of the bytes, a BlobId
both names a blob and lets any holder verify that the bytes it received are the
bytes that were asked for.
Blobs are the protocol’s mechanism for moving payloads that are too large, too binary, or too rarely-read to belong inside replicated state: WASM application bundles, file attachments, media, and similar. The state tree carries the small, hot, causally-ordered data; blobs carry the bulk, referenced by id.
pub struct BlobId(Hash); // 32-byte digest, base58 on displayWhere blobs sit relative to state
Section titled “Where blobs sit relative to state”Blobs are not folded into a scope’s Merkle root. The convergence machinery
described in Storage and Sync compares
scope_root over state entities; blob bytes never enter that hash. State
references a blob only by storing its BlobId as a value. Two consequences
follow:
- Announcing, transferring, or garbage-collecting a blob does not change any scope root and cannot, by itself, cause or repair state divergence.
- A node can hold a
BlobIdin state without holding the bytes. The bytes are fetched lazily, on demand, over the transfer paths below — and a peer that serves them is checked against the referencing context, not against the state root.
Blob bytes live in their own storage, separate from the replicated entity tree:
metadata in the Blobs column and the raw chunk files on disk.
// crates/store/src/types/blobs.rs — value stored in the `Blobs` columnpub struct BlobMeta { pub size: u64, // total size in bytes pub hash: [u8; 32], // SHA-256 over the whole file content pub links: Box<[key::BlobMeta]>, // chunk blobs, in order (empty for a leaf chunk)}Chunked storage layout
Section titled “Chunked storage layout”On write, the blob store reads the input stream in fixed CHUNK_SIZE = 1 MiB
pieces. Each chunk is persisted as its own blob whose BlobId is the hash of
that chunk’s bytes. The root blob is then the ordered list of those chunk
ids: its BlobId is the hash of the concatenated chunk ids, and its BlobMeta
carries the total size, the whole-content hash, and the links to the
chunk blobs. A blob that fits in a single chunk still gets a root whose id is
derived from its one chunk id.
root BlobId = hash( chunk_id[0] ‖ chunk_id[1] ‖ … ) // BlobMeta.linkschunk BlobId = hash( chunk_bytes ) // 1 MiB each, last may be shortBlobMeta.hash = hash( whole file content ) // distinct from BlobIdBecause a chunk’s id is the hash of its bytes, two blobs that share identical
1 MiB chunks resolve to the same chunk BlobId and are stored once —
deduplication falls out of content addressing with no extra bookkeeping. A
delete(blob_id) path exists to drop a blob’s stored bytes
(crates/store/blobs/src/lib.rs), but content-addressed garbage collection of
chunks no longer referenced from state is not yet implemented: removing a state
reference does not by itself reclaim the bytes.
The guest blob API
Section titled “The guest blob API”Applications never touch storage or the network directly. They use host
functions exposed to the WASM guest. Writing is a streaming
create → write* → close sequence that yields a BlobId; reading is
open → read*. A blob can be published to a context for discovery with
announce_to_context.
blob_create() -> fdblob_write(fd, src: Buffer) -> bytes_writtenblob_close(fd, dst: BufferMut[32]) -> 1 // writes the final BlobId into dst[0..32]blob_open(blob_id: Buffer[32]) -> fdblob_read(fd, dst: BufferMut) -> bytes_read // 0 at end of blobblob_announce_to_context(blob_id: Buffer[32], context_id: Buffer[32]) -> 1-
blob_create()opens a write handle and returns an integer file descriptor (fd). Internally this spins up a background task that consumes written chunks and feeds them into the blob store. -
blob_write(fd, src)appends one chunk. The number of bytes written equals the source buffer length. Each call’s buffer must not exceedmax_blob_chunk_size. -
blob_close(fd, dst)finalises the upload, computes theBlobId, and writes those 32 bytes into the guest bufferdst. The handle is consumed. For a read handle,closesimply releases it. -
blob_open(blob_id)opens a read handle for an existing blob and returns anfd. The bytes are streamed lazily — opening does not yet fetch anything. -
blob_read(fd, dst)copies the next bytes intodstand returns the count, which may be less than the buffer (a partial chunk is buffered internally and carried to the next call). A return of0means end of blob. The read position only advances after the copy into guest memory succeeds, so a failed copy can be retried without losing data.
Limits and errors
Section titled “Limits and errors”| Limit | Default | Enforced by |
|---|---|---|
max_blob_handles | 100 | blob_create / blob_open (open handles per execution) |
max_blob_chunk_size | 10 MiB | blob_write (per-write) and blob_read (per-read buffer) |
The host functions surface these errors:
BlobsNotSupported— the node is configured without a blob/network client (the API is unavailable, e.g. in a bare VM).TooManyBlobHandles— opening would exceedmax_blob_handles.InvalidBlobHandle— unknownfd, or using a write handle where a read handle is required (or vice versa).BlobWriteTooLarge/BlobBufferTooLarge— the write chunk or the read buffer exceedsmax_blob_chunk_size.InvalidMemoryAccess— a descriptor buffer is out of bounds, or theBlobIddestination forblob_closeis not exactly 32 bytes.
Announce and discover
Section titled “Announce and discover”A blob is announced so peers in a context can locate the bytes.
blob_announce_to_context(blob_id, context_id) looks up the blob’s size from
local metadata and publishes a record to the Kademlia DHT
(Networking).
DHT key = context_id ‖ blob_idDHT value = local_peer_id ‖ size (size as little-endian u64)The record is stored with Quorum::One. The key is scoped by context_id, so
the same blob announced into different contexts is discovered independently, and
a discovery query is always made in the context of a specific context.
Discovery is the mirror: a node that holds a BlobId in state but not the bytes
queries the DHT for the same context_id ‖ blob_id key to learn which peers
advertised it. The lookup is retried with exponential backoff — up to 6
attempts, starting at 100 ms and doubling to a 2 s cap — because a
freshly announced record may take a few hundred milliseconds to become visible.
Each returned peer is then asked for the bytes over the transfer protocol below;
the first peer that delivers a blob whose recomputed id matches wins, and the
bytes are stored locally for reuse.
Point-to-point transfer over the dedicated stream
Section titled “Point-to-point transfer over the dedicated stream”Once a provider peer is known, the bytes move over a dedicated libp2p stream protocol, separate from the sync stream:
pub const CALIMERO_BLOB_PROTOCOL: StreamProtocol = StreamProtocol::new("/calimero/blob/0.0.2");pub const MAX_MESSAGE_SIZE: usize = 8 * 1_024 * 1_024; // 8 MiB framed-message capThe requester opens a stream on CALIMERO_BLOB_PROTOCOL, sends a single
BlobRequest, and reads a BlobResponse header followed by a sequence of
BlobChunk frames terminated by an empty chunk.
struct BlobRequest { blob_id: BlobId, context_id: ContextId, auth: Option<BlobAuth> } // JSONstruct BlobResponse { found: bool, size: Option<u64> } // JSONstruct BlobChunk { data: Vec<u8> } // BorshThe provider streams its stored chunks straight from the blob store, one
BlobChunk per stored chunk, then sends an empty BlobChunk to mark the end.
If the provider does not hold the blob it replies BlobResponse { found: false }
and sends no chunks.
Authorization
Section titled “Authorization”Blob bytes are not served to anyone who asks. The provider authorizes each
BlobRequest before streaming:
- Public bundles. If the requested
blob_idis the context’s application bytecode or compiled artifact (per the authoritative context config), access is granted with no signature. This is what lets a joining node fetch the code it needs before it has an identity in the context. - Signed member requests. Otherwise the request must carry a
BlobAuth: the requester’spublic_key, a Unixtimestamp, and an Ed25519signatureover theBlobAuthPayload { blob_id, context_id, timestamp }. The provider checks the signature, checks that the timestamp falls inside the replay window (30 s in the past, 10 s in the future), and checks that the public key is a current member of the context. Any failure denies access.
struct BlobAuth { public_key: PublicKey, signature: [u8; 64], timestamp: u64 }struct BlobAuthPayload { blob_id: [u8; 32], context_id: [u8; 32], timestamp: u64 }Timeouts and framing
Section titled “Timeouts and framing”Both ends bound the transfer so a stalled peer cannot pin resources:
| Side | Bound | Value |
|---|---|---|
| Requester | whole transfer | 60 s |
| Requester | per chunk received | 30 s |
| Provider | whole serve | 300 s |
| Provider | per chunk sent | 30 s |
Every frame is length-delimited and capped at MAX_MESSAGE_SIZE (8 MiB) by the
stream codec. Since stored chunks are 1 MiB, a BlobChunk frame stays well
under that cap.
Worked example: a 5 MB image
Section titled “Worked example: a 5 MB image”To see the pieces fit together, follow a 5 MB image from the app that stores it to a peer that fetches it.
-
Store it. The producing app calls
blob_create(), streams the image in with repeatedblob_write(fd, …), and callsblob_close(fd, dst). The blob store re-chunks the byte stream into roughly five 1 MiB storage chunks (the last one short), andclosewrites back the rootBlobId— the hash of the ordered chunk-id list. The app keeps that id (typically by storing it in state). -
Announce it.
blob_announce_to_context(blob_id, context_id)looks up the blob’s size locally andput_records a Kademlia record keyedcontext_id ‖ blob_idwith valuelocal_peer_id ‖ sizeatQuorum::One. The image is now discoverable by any member of that context. -
Discover it. A second node holds the
BlobIdin state (it synced the reference) but not the bytes. Itget_records the samecontext_id ‖ blob_idkey — retried with backoff, since a fresh record takes a moment to propagate — and learns the announcing peer and the size. -
Fetch it. The fetcher opens
/calimero/blob/0.0.2to that peer and sends aBlobRequest { blob_id, context_id, auth? }. The provider authorizes the request, repliesBlobResponse { found: true, size }, then streams its stored chunks asBlobChunkframes — one per 1 MiB chunk — and a final emptyBlobChunkto mark the end. -
Verify it. The fetcher concatenates the chunks, recomputes the
BlobId, and accepts the bytes only if it matches the id it asked for. It stores the image locally and can now serve (and announce) it itself.
Blob transfer during sync
Section titled “Blob transfer during sync”Blobs can also move inside a sync session over the sync stream
(/calimero/stream/0.0.2), rather than the dedicated blob protocol. This path
is used when a sync needs a blob it does not yet hold — for example staging an
application upgrade. It rides the same encrypted, sequenced envelope as every
other sync exchange (Sync), so the wire messages differ from
the dedicated protocol:
InitPayload::BlobShare { blob_id: BlobId } // request (and the responder's ack)MessagePayload::BlobShare { chunk: Cow<'a, [u8]> } // each chunk; empty chunk ends the streamThe initiator sends an Init carrying BlobShare { blob_id }; the responder
acks with the same blob_id, then streams Message frames each carrying a
BlobShare chunk, ending with an empty chunk. Unlike the dedicated protocol,
these frames are encrypted under the per-session shared key and ordered by the
sync sequencer, and the chunk payload is raw bytes (not a Borsh BlobChunk). A
responder that does not hold the blob replies with an opaque error so the
initiator fails fast and retries after its backoff rather than blocking.
Summary
Section titled “Summary”- A blob is content-addressed immutable bytes named by
BlobId, stored outside the state tree in theBlobscolumn plus on-disk chunk files, and re-chunked to 1 MiB internally. - Blobs are referenced from state by id only; they are never folded into
scope_root, so blob movement neither creates nor repairs state divergence. - Guests use
create → write → close(yields aBlobId),open → read, andannounce_to_context, bounded bymax_blob_handlesandmax_blob_chunk_size. - Discovery is a context-scoped Kademlia lookup; transfer runs either over the
dedicated
/calimero/blob/0.0.2stream (BlobRequest→BlobResponse→BlobChunk*) or, during sync, as encryptedBlobShareframes — both gated by context-membership authorization and both self-verifying by content address.