Tiled storage and proofs — a guide to merklecpp_tiles.h#
merklecpp_tiles.h is an optional, header-only companion to merklecpp.h. It
lets you persist a Merkle tree as a set of immutable tile files on disk and
serve inclusion and consistency proofs from those tiles, from the
in-memory tree, or from a combination of the two — so proofs stay available even
after old entries are dropped from memory.
It builds on the tlog-tiles file/directory layout
but is not trying to be wire-compatible with external tlog-tiles clients.
merklecpp adds an algorithm-qualified directory above the standard tile/
layout so different hash functions can safely share one configured prefix.
This page is a practical how-to; see the
illustrated walkthrough for a visual model of the
tile layout and proof algorithms.
Requirements and a note on hashing#
C++20. Both headers use
std::format; tile storage additionally uses<filesystem>and small platform-specific file-sync calls.Include the companion header; it pulls in
merklecpp.hfor you:#include <merklecpp_tiles.h>
Everything lives in
namespace merkle::tilesand is templated on the same<HASH_SIZE, HASH_FUNCTION>as your tree. The default aliases (merkle::tiles::TiledTree,TileStore,TileWriter,ProofEngine, …) use the same SHA-256 asmerkle::Tree, so a tile-derived inclusion proof is byte-identical to one frommerkle::Tree::path()and verifies with the usualmerkle::Path::verify().With
OPENSSL=ON, correspondingTiledTree384/TiledTree512,TileStore384/TileStore512, writer, source, proof-engine, and entry-bundle aliases use the OpenSSL-backed SHA-384 and SHA-512 functions.A custom hash function must use the constructor overload that supplies a lowercase storage name, for example
MyTiledTree(cfg, "custom-sha256"). Otherwise construction throws because merklecpp cannot infer the namespace. Names may contain lowercase letters, digits, and internal hyphens. The reservedsha256,sha384, andsha512names must match the configured hash size.You insert leaf hashes, not raw entries — exactly like
merkle::Tree. Deriving a leaf hash from an entry (e.g.leaf = H(entry)) is your application’s job. The tile hash values are whatever yourHASH_FUNCTIONproduces; they are not RFC 6962 unless you instantiate your tree with an RFC 6962 hash function (not required, and not the goal here).
Thread safety#
The tiled-storage API provides no internal synchronization. Treat each
TileStore, TileWriter, TileHashSource, ProofEngine, TiledTree, and
EntryBundleWriter instance as single-threaded.
Serialize access to any one object, including methods declared const:
tile-backed proof generation updates the TileHashSource LRU cache. Writers
sharing a prefix must also be serialized. Independent TileStore objects may
read that prefix concurrently with the single serialized writer because tiles
are published atomically and only appear at their final path when complete.
Quick start: TiledTree#
TiledTree is the high-level wrapper: append leaf hashes, flush them to disk
(which writes tiles), and ask for proofs. This example is included directly
from the tiles_docs test so the documented code is compiled and run.
TiledTree::Config cfg;
cfg.prefix = storage_directory;
TiledTree log(cfg);
for (const Hash& leaf : batch)
{
log.append(leaf);
}
// Persist newly-complete tiles to disk.
log.flush();
const auto n = log.size();
if (n == 0)
{
throw std::runtime_error("expected at least one leaf");
}
const Hash root = log.root();
// Inclusion proof for leaf 0 in the tree of n leaves.
const auto inclusion = log.inclusion_proof(0, n);
if (!inclusion->verify(root))
{
throw std::runtime_error("inclusion proof did not verify");
}
This example assumes batch is non-empty. root() throws on an empty tree;
size(), flush(), and compact() are safe at size 0.
TiledTree can be move-constructed, but it cannot be copied or assigned. Move
construction keeps its writer bound to the destination tree’s tile store.
Config::prefix is resolved to an absolute path when the store is constructed.
A relative prefix therefore binds to the working directory at that moment;
later working-directory changes do not move the tile store.
TiledTree always creates a new tiled tree. The configured prefix may already
exist, but the algorithm-qualified tile namespace must not: the default alias
atomically creates <prefix>/sha256-256w/tile and rejects it whenever it already
exists, even if it is empty. Construction does not adopt existing tiles because
those files do not identify the tree that produced them or contain enough state
to restore its size and root. If your application persists and validates that
state separately, use the lower-level TileStore and TileWriter APIs;
TileWriter intentionally resumes existing full tiles and therefore trusts the
caller to supply the same tree and hash function. A fresh writer scans the
requested range in order, stopping at the first missing or malformed file, so
an interior hole is rewritten rather than hidden by later files.
flush() is incremental: each call writes only the full tiles that became
complete since the previous call. Full tiles are immutable: written once after
all 256 entries are final and never rewritten. The remaining frontier stays in
memory until it crosses the next full-tile boundary.
Tile files are written through unique temporary files, synced, then published with an atomic replace. On POSIX, file contents are synced, each newly created directory is made durable by syncing its parent, and the destination directory is synced after the rename. Before reusing a visible file, a writer also re-confirms its directory chain and destination directory. On Windows, file contents are flushed and the rename is write-through, but directory syncs are no-ops and directory-entry durability is left to the filesystem. A wrong-size file at a tile path is treated as unpublished and is rewritten when the source leaves are still resident.
Flushing and compaction#
By default flush() only writes tiles; it keeps every leaf resident in
memory. Dropping already-tiled leaves from memory (“compaction”) is opt-in,
because once you drop them you can only prove them from the tiles.
merkle::tiles::TiledTree::Config cfg;
cfg.prefix = "/var/log/mylog";
cfg.compact_on_flush = true; // drop tiled leaves after each flush
cfg.retention_margin = 4096; // keep at least 4096 tiled leaves resident too
merkle::tiles::TiledTree log(cfg);
compact_on_flush(defaultfalse): when set,flush()callscompact()for you.compact()can also be called explicitly at any time. It drops from memory only leaves already covered by a successfully published full tile.retention_marginis counted back fromflushed_size(), then rounded down to a tile boundary; the un-tiled frontier is always resident in addition to the margin, and alignment may retain up toTILE_WIDTH - 1extra tiled leaves. The final tiled leaf is also retained so rollback to exactlyimmutable_size()remains representable. Treat the margin as a minimum, not an exact resident count.compact()returns the new minimum (smallest still-resident) leaf index.Proofs for dropped leaves are still produced — they are served from the tiles and transparently combined with the resident frontier.
flushed_size() is the boundary completed successfully at every required tile
level, and it is the only boundary used for proof reads and compaction.
immutable_size() is the rollback boundary. A flush seals that boundary before
it starts writing, because an error can occur after a full tile becomes visible.
If a flush throws, immutable_size() may advance while flushed_size() does
not. Keep the same tree contents, correct the I/O failure, and retry flush();
finalized tiles are reused rather than rewritten.
Protect compacted tile files from external deletion or truncation. Once a
tile’s leaves are no longer resident, flush() cannot regenerate a missing or
malformed copy and throws an error naming the first non-resident leaf. Restore
the tile from backup; retrying alone cannot recover it.
log.compact(); // free memory now
auto resident_from = log.tree_ref().min_index();
Rollback#
Tiles are immutable, so you may only roll back entries beyond the boundary
returned by immutable_size(). retract_to enforces this:
log.retract_to(index); // keep leaves [0, index], drop the rest
Allowed when the resulting size is
>= immutable_size().Throws otherwise, because a flush may already have published an immutable full tile for that range.
The exact
immutable_size()boundary remains available after compaction; compaction retains the final tiled leaf needed by the in-memory tree.After a successful flush,
immutable_size() == flushed_size(). After an interrupted flush,immutable_size()may be larger until the same tree state is flushed successfully.retract_tomirrorsmerkle::Tree::retract_to:indexis the new last leaf, so the resulting size isindex + 1.An
indexat or beyond the current last leaf is a no-op.
Warning
Treat tree_ref() as an inspection escape hatch unless you also
maintain every tiled-tree invariant yourself. Direct retraction bypasses the
guard, can make flushed_size() and immutable_size() exceed size(), and
can make flushed_size() regress. Use TiledTree::retract_to instead.
store_ref() is similarly unsafe for direct Merkle-tile mutation. A later
flush trusts any correctly sized tile written through it without checking that
the hashes match the in-memory tree. A mismatched tile can silently invalidate
proofs after compaction. Constructing an EntryBundleWriter over store_ref()
is safe because entry bundles are separate application data and are never used
to resolve Merkle proofs.
Proofs#
Both proof types come from TiledTree (or, at a lower level, from
ProofEngine). They are produced with your tree’s hash function, so they match
what merkle::Tree would produce. Requests outside the current tree (e.g. a
size greater than size(), or an out-of-range index) throw std::runtime_error
rather than returning an incorrect proof.
Inclusion proofs#
// Prove leaf `index` in a tree of `size` leaves.
std::shared_ptr<merkle::Path> p = log.inclusion_proof(index, size);
bool ok = p->verify(root_at_size);
size is the tree size you are proving against:
size == log.size()⇒ equivalent tomerkle::Tree::path(index); verify againstlog.root().a past
size⇒ equivalent tomerkle::Tree::past_path(index, size - 1); verify against the root at that size (e.g. a past root you are auditing).
size may even exceed flushed_size(): the recent, not-yet-tiled frontier is
taken from the resident tree while the older part comes from tiles.
Consistency proofs#
std::vector<merkle::Hash> proof = log.consistency_proof(m, n); // m <= n
bool ok = merkle::tiles::ProofEngine::verify_consistency(
m, n, old_root /* root at size m */, new_root /* root at size n */, proof);
verify_consistency is a static helper, so you can verify on a client that only
has the two roots and the proof.
The arguments are tree sizes (leaf counts). If you have leaf indices
instead, use the variant that maps index i to the tree of size i + 1 (the
“last leaf” convention, matching past_path/retract_to):
// Equivalent to consistency_proof(i + 1, j + 1).
auto proof = log.consistency_proof_from_indices(i, j); // i <= j
Both TiledTree and the lower-level ProofEngine provide
consistency_proof_from_indices.
Lower-level building blocks#
If you manage your own tree/storage you can use the pieces directly instead of
TiledTree.
Index types and object lifetimes#
TiledTree mirrors merkle::Tree and uses size_t for indices and counts. The
lower storage and proof layer (TileStore, TileWriter, hash sources,
ProofEngine, and EntryBundleWriter) uses uint64_t, matching tlog-tiles;
that is why leaf and entry callbacks below take uint64_t. Conversions back to
the in-memory tree’s index type are range-checked rather than truncated.
Low-level objects own none of the objects passed to them. A store must outlive
its writers and tile sources; a tree must outlive its memory source; sources
must outlive a CombinedHashSource and ProofEngine. Returned proofs hold hash
copies and may safely outlive the engine that produced them.
Writing tiles from your own tree#
merkle::Tree tree;
for (auto& leaf : batch) tree.insert(leaf);
merkle::tiles::TileStore store("/var/log/mylog");
merkle::tiles::TileWriter writer(store);
// Write all newly-complete full tiles; keep the remaining frontier in memory.
auto stats = writer.write_up_to(
tree.num_leaves(),
[&](uint64_t i) -> const merkle::Hash& { return tree.leaf(i); });
// stats.full_written
TileWriter keeps an in-memory next-file cursor. A new writer reconstructs it
by checking the contiguous prefix only up to the number of full files relevant
to the requested tree size. Existing files are re-confirmed as durably
published before reuse; malformed files and holes are rewritten.
Reading tiles and computing proofs#
A HashSource resolves the root of a complete subtree; pick where it reads from:
TileHashSource(store, available_size)— from full tile files; resolves the full-tile-covered prefix only (the frontier needs a memory source).MemoryHashSource(tree)— from a residentmerkle::Tree.CombinedHashSource(primary, secondary)— tryprimaryfirst, thensecondary(e.g. memory then tiles).
const uint64_t available = tree.num_leaves();
const uint64_t tiled =
available - (available % merkle::tiles::TILE_WIDTH);
merkle::tiles::TileHashSource src(store, available);
merkle::tiles::ProofEngine engine(src);
if (tiled > 0)
{
merkle::Hash root = engine.root(tiled);
auto inclusion = engine.inclusion_proof(/*index=*/0, tiled);
}
if (tiled > 1)
{
auto consistency = engine.consistency_proof(/*m=*/tiled / 2, tiled);
}
A tile-only source can resolve proofs whose subtrees all lie within the
full-tile-covered prefix (available_size is rounded down to a whole number of
tiles); requests beyond tiled throw. For the live frontier, combine it with a
MemoryHashSource — which is exactly what TiledTree does for you.
TiledTree simply wires a CombinedHashSource(MemoryHashSource, TileHashSource)
into a ProofEngine for you. It creates these sources for each proof call, so
its tile cache is per-call and repeated tile-served proofs may re-read the same
files. A long-lived lower-level TileHashSource retains its 64-tile LRU cache
across calls; reuse it with a ProofEngine when that matters, and serialize
access.
Entry bundles (optional)#
If you also want to store the raw log entries (tlog-tiles “entry bundles”), use
EntryBundleWriter. Bundles are level-0 only and application-owned — merklecpp
stores leaf hashes; you supply the raw bytes and decide how an entry maps to its
leaf hash. Only full bundles (256 entries) are written; the incomplete tail
stays with your application until it completes a bundle.
merkle::tiles::EntryBundleWriter bundles(store);
bundles.write_up_to(num_entries,
[&](uint64_t i) -> std::vector<uint8_t> { return raw_entry_bytes(i); });
// Read a full bundle back (256 entries).
std::vector<std::vector<uint8_t>> e = store.read_entry_bundle(/*index=*/0);
Entries are encoded as big-endian uint16 length-prefixed byte strings.
Each entry is therefore limited to 65,535 bytes; a larger entry throws. Bundles
live in the same algorithm-qualified store at tile/entries/<index>, although
their bytes are application-owned rather than hash-dependent. Missing or
malformed bundles break the contiguous prefix and are rewritten by a new writer
when it resumes.
With TiledTree, construct the writer from the exposed store:
merkle::tiles::EntryBundleWriter bundles(log.store_ref());
On-disk layout#
Under the configured prefix, merklecpp adds an algorithm-qualified format
directory above the standard tlog-tiles layout:
<prefix>/
sha256-256w/ # algorithm-qualified format directory
tile/0/000, tile/0/001 … # level-0 tiles (leaf hashes), 256 hashes each
tile/1/… # higher levels (roll-ups of full tiles below)
tile/entries/… # optional raw entry bundles
Tile indices use the tlog-tiles path encoding: zero-padded 3-digit groups with
all but the last prefixed by x (e.g. index 1234067 -> x001/x234/067). Every
tile is full (256-wide), final, and immutable. Entries beyond the last full-tile
boundary remain in memory. The built-in SHA-384 and SHA-512 aliases use
sha384-256w and sha512-256w; a custom hash uses the explicit algorithm name
passed to its constructor. TileStore::root() returns this format directory,
not the configured prefix. Levels range from 0 through 63; every successful
flush writes all full roll-ups required by the current tree size. See the
tlog-tiles specification for the standard
geometry and the illustrated walkthrough for how
merklecpp stores and resolves those tiles.