chainfold
A sans-io fold engine for ordered chain events, with fork recovery and durable snapshots.
chainfold folds ordered chain events into consumer state: engine-enforced total order over (block, log_index), fork detection by ancestry commitment, bounded rollback to retained checkpoints, and a durable snapshot envelope.
how it works
A relayer or wallet polls logs from one chain and folds them into local state. The chain reorgs, the node lags, the process restarts. Every consumer rewrites that loop, and the loop is where the bugs live: skip-and-diverge on a failed event, a cursor that is not durable, a reorg noticed only when a proof stops verifying.
- apply: a poll fills one
Batch; theEngineenforces the total order, drops everything at or below the cursor as already applied, and counts the events the fold declares are not its own. - detect: each batch carries the current header of the cursor block. A block hash commits to its whole ancestry, so a reorg touching anything at or below the cursor changes that one hash. One header per poll, one 40-byte compare.
- recover: on a mismatch, the driver bisects the ring of observed blocks with
header_atfor the deepest still-canonical block, rolls back to the newest checkpoint at or below it, and refolds forward.O(log W)probes, then deterministic replay. - persist: the driver offers the oldest retained checkpoint to a sink; a flusher thread fsyncs it. The durable cursor trails the applied cursor and names what a restart recovers, so the apply path never fsyncs and replay closes the gap.
flowchart LR
C[(chain)] -->|"head, header_at, events_in"| D[Driver]
D -->|apply_batch| E[Engine]
E --> K["K checkpoint slots"]
K -->|offer| F[Flusher]
F -->|"fsync, rename, fsync dir"| P[(snapshot store)]
E -.boundary hash mismatch.-> B{bisect ring}
B -->|ancestor found| R[rollback to checkpoint]
B -->|below the window| Y[resync or terminal]
R --> D
Fold state is fully generic; the crate pins no provider, no runtime, and no storage engine in its core. This approach makes tradeoffs specific to its callers and is not intended for production use.
design
Three layers, each usable without the one above it: the Engine (no_std + alloc,
sans-io, single writer), the Driver (sans-runtime, owning cadence, backoff, scanning,
and recovery), and the adapters (std: tokio harness, snapshot store, flusher).
Positionis(block, log_index).log_indexis block-unique on EVM, so a tx index would be redundant and invite inconsistent adapters.- dedup is positional only. The cursor is the sole mechanism; there is no seen-set to grow without bound.
FoldErroris classified at the fold, not guessed by the engine:Skip(not mine, counted and stepped over),Halt(clean below this position, rollback or resync),Poison(partially mutated, untrusted until a restore).Sourceis three reads:head,header_at,events_in. The driver owns the window walk, the scan mark, the boundary refetch, and the grouping into spans, so a source cannot get the batch shape wrong and every source gets fork detection.Ok(None)from a probe means suspected fork.- rollback is bounded to K retained checkpoints. Over-rollback past still-canonical but unobserved blocks is intended; the discarded events replay deterministically.
- the recovery ladder is rollback, then resync from genesis, then a typed terminal state.
DivergenceCausenames which rung ran out: fork deeper than the window, no checkpoint below the ancestor, source horizon short of the start block. EngineStatus::Unrecoverableis only left by areset. Automated progress stops rather than folding on untrusted state.- the snapshot envelope is engine-owned: magic, format version, fold identity tag, cursor, observed ring, state bytes, CRC32C trailer. The ring travels inside it, so a restarted engine detects a reorg that happened while it was down.
- lengths decoded from stored bytes are bounded before any allocation and every offset is checked, so a corrupt length prefix is a typed error rather than an OOM primitive.
- the durable cursor rides the manifest as a watermark. Commit writes a temp file, fsyncs it, renames, then fsyncs the directory; the manifest is dual-slot, and a commit overwrites the older slot so a torn write only damages the copy already superseded.
- the watermark names the snapshot the manifest points at, so a resync that persists state from genesis lowers it. Reporting the old height would promise a restart point the store no longer holds, and holding the pre-resync snapshot would recover state folded on a chain that no longer exists.
- the observed ring is structure-of-arrays with power-of-two capacity, so bisection binary searches the number lane alone and touches one hash at the end. W = 1024 is 40 KiB.
Batchis flat: one contiguous event array plus a span array indexing into it. Two allocations per poll, both pooled and reused by the driver.- the apply path allocates zero. That is a CI assertion under a counting allocator, not a convention.
- one engine per chain. Coordinated cross-chain rollback is an explicit non-goal; consumers join across chains themselves.
usage
fold and engine (default-features = false)
use ;
let mut engine = new?;
// a batch per poll: ordering, dedup, boundary recheck, fork detection
let summary = engine.apply_batch?;
engine.checkpoint;
driver and harness
use ;
let mut driver = new?;
// sans-runtime: the whole loop a consumer without tokio writes
loop
// or, with the tokio feature
let mut handle = spawn;
let status = handle.wait_caught_up.await;
A suspected fork bisects to the ancestor and rolls back; only a fork below every retained
checkpoint escalates to a resync. with_sink and the resume* constructors add a
SnapshotSink.
durability (storage feature)
SnapshotStore::open returns whatever the last commit made durable, Engine::decode_snapshot
rebuilds an engine from it, and resume polls onward from the durable cursor. Flusher is
the SnapshotSink in between: the driver offers, the flusher thread fsyncs, and the
watermark moves onto the committed snapshot only when the fsync returns.
examples/replay.rs runs the whole shape end to end: fold to the tip under a live flusher,
survive a reorg, then restart from the durable cursor and converge on the same state.
Features
| feature | pulls in | notes |
|---|---|---|
| (default) | nothing | no_std + alloc core: engine, driver, traits |
std |
crc-fast | runtime-detected SIMD CRC32C, std error plumbing |
serde |
serde | derives on Position and BlockRef |
wincode |
wincode, crc | snapshot envelope, Persist, encode_snapshot / decode_snapshot |
storage |
std + wincode |
SnapshotStore, Flusher, Vfs |
tokio |
tokio | harness::spawn, watch-backed status, wait_caught_up / wait_durable |
test-helpers |
nothing | RecordingFold, NoopFold, ScriptedChain, WatermarkSink, CrashVfs |
docs |
include-utils | folds this README into the crate docs |
default = []: the core is the part every consumer wants, and the adapters each drag in a
dependency stack that a guest or wasm build has no use for.
Tuning
EngineConfig::ring_capacity(W): observed-block window, a power of two in[2, 1 << 20]. It bounds fork detection; a fork deeper than the oldest ring entry isForkBeyondWindow.EngineConfig::checkpoint_slots(K): retained rollback points. Zero disables rollback, soHaltandPoisonescalate straight to resync and anchor checks never fire. That is the default shape of most consumers today, not an edge case.DriverConfig::checkpoint_interval: blocks of cursor progress between automatic checkpoints. The reorg an offered snapshot survives without a resync ischeckpoint_slots * checkpoint_intervalblocks; size both against the deepest reorg your chain produces.DriverConfig::snapshot_interval: blocks of durable-point progress between sink offers.Nonedisables offers entirely.DriverConfig::poll_interval,backoff_base,backoff_max: cadence at the tip, and the capped exponential backoff after a source error. A tick that moved the cursor asks for no delay, so catch-up and post-resync replay run at source speed.Flusher::spawn(store, queue_depth): snapshot jobs admitted before an offer blocks. The bound is the backpressure signal when the disk falls behind.StoreConfig::retain: superseded snapshot files kept beyond the two the manifest names, minimum 1.MAX_ENVELOPE_FIELD_LEN(1 GiB, compile-time): ceiling on a length-bearing envelope field. It caps the allocation a decoded length prefix can drive, and encoding past it isSnapshotError::TooLarge.
development
Prerequisites
- cargo-hack: to test all combinations of feature flags
- cargo-nextest: rust test runner
Check
Clippy
Format
Testing
hack.toml caps the powerset at depth 2 and groups serde with wincode; the full
product is mostly redundant given the feature graph.
Three integration tests carry the properties the design is judged on. restart_identity
is a proptest for byte-identical restart: an engine that snapshots mid-run and one that
decodes that snapshot encode the same bytes after folding the same blocks, ring wrap
included. apply_alloc runs the apply loop under a counting global allocator and asserts
zero allocations, and bounds what a corrupt envelope can allocate on decode.
storage_crash fuzzes crash schedules through CrashVfs across repeated open-commit
cycles, asserting recovery always lands on a committed prefix.
Benchmarks
chainfold::apply measures engine overhead through NoopFold, so the number is the
bookkeeping alone; chainfold::snapshot_encode and chainfold::snapshot_decode measure
the envelope codec in bytes per second. Both are feature-gated; see the
Cargo.toml entry for the exact flags.