merkle-crdt
Skill
merkle-crdt. Use when writing Dart code against the merkledag package (import ‘package:merkledag/merkledag.dart’): building a MerkleCRDT or MerkleClock, writing a CRDTPayload or using GSet, implementing DAGSyncer or Broadcaster, encoding or verifying MerkleNode bytes, or testing replicas locally. For libp2p replication use merkle-crdt-sync.SKILL.md (Claude Code, Codex, Cursor) · merkle-crdt.md (Boubou) · install:
curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- merkle-crdt
What it is
Section titled “What it is”merkledag is a Dart implementation of Merkle-CRDTs (Sanjuán et al., arXiv
2004.00107): a content-addressed Merkle-DAG whose nodes carry state-based CRDT
payloads, so replicas that see the same heads converge without coordination.
It is transport-agnostic: you supply a DAGSyncer (fetch and store nodes by
CID) and a Broadcaster (announce and receive head CIDs). It has no
networking of its own.
Use something else when:
- you want replication between dart_libp2p peers (heads over GossipSub, DAG
blocks over streams): load the
merkle-crdt-syncskill (dart_libp2p_merkle_crdt), which implements both interfaces for you; - you need generic IPFS blocks or Bitswap: load
dart-libp2p-bitswap. The nodes here are not dag-pb (see Gotchas).
Install
Section titled “Install”dependencies: merkledag: ^2.0.0 dcid: ^1.0.0 # CID is not re-exported; you need it to name or parse CIDsimport 'dart:typed_data';import 'package:dcid/dcid.dart'; // CID, CID.fromString, CID.fromBytesimport 'package:merkledag/merkledag.dart'; // MerkleCRDT, MerkleClock, MerkleNode, GSet, ...SDK constraint: ^3.5.0. Runtime deps: crypto, dcid, collection,
logging, synchronized.
Core workflow
Section titled “Core workflow”Minimal in-memory replica
Section titled “Minimal in-memory replica”The package does not export test doubles (the MockDAGSyncer and
MockBroadcaster used in the README live in test/). Write your own:
import 'dart:async';import 'package:dcid/dcid.dart';import 'package:merkledag/merkledag.dart';
class MemorySyncer<T> implements DAGSyncer<T> { final Map<CID, MerkleNode<T>> nodes = {}; @override Future<MerkleNode<T>?> get(CID cid) async => nodes[cid]; @override Future<void> put(MerkleNode<T> node) async => nodes[node.cid] = node;}
class LocalBus implements Broadcaster { final _controller = StreamController<String>.broadcast(); @override Future<void> broadcast(String cidString) async => _controller.add(cidString); @override Stream<String> subscribe() => _controller.stream;}
Future<void> main() async { final crdt = MerkleCRDT<Set<String>, GSet<String>>( dagSyncer: MemorySyncer<GSet<String>>(), broadcaster: LocalBus(), );
final head = await crdt.add(GSet<String>()..add('apple')..add('banana')); await crdt.add(GSet<String>.fromList(['cherry']));
final state = await crdt.getState(); // GSet<String>? (null when empty) print(state?.value); // {apple, banana, cherry} print(crdt.roots); // Set<CID>, normally one head print(head); // bafy... (CIDv1, base32)
await crdt.close(); // cancels the broadcast subscription}add(payload) merges payload into the current state, creates one node whose
children are all current roots, puts it, makes it the single root, and then
broadcasts its CID (outside the lock). If the merged state is byte-identical
to the single current head, nothing is created and the existing head is
returned without a broadcast.
Two replicas converging
Section titled “Two replicas converging”Replicas share nothing but the DAGSyncer (so they can fetch each other’s
nodes) and the broadcast channel:
final store = MemorySyncer<GSet<String>>();final bus = LocalBus();final a = MerkleCRDT<Set<String>, GSet<String>>(dagSyncer: store, broadcaster: bus);final b = MerkleCRDT<Set<String>, GSet<String>>(dagSyncer: store, broadcaster: bus);
await a.add(GSet<String>.fromList(['x']));await b.add(GSet<String>.fromList(['y']));await Future<void>.delayed(Duration.zero); // let broadcasts be handled
// Each may now hold two concurrent roots; state is already merged.print((await a.getState())!.value); // {x, y}await a.add(GSet<String>()); // folds concurrent roots into one headFeeding heads in yourself
Section titled “Feeding heads in yourself”Pass subscribe: false and call merge when you want to authorise,
deduplicate or rate-limit heads before they are applied:
final crdt = MerkleCRDT<Set<String>, GSet<String>>( dagSyncer: syncer, broadcaster: broadcaster, // still used by add() to announce subscribe: false,);
Future<void> onRemoteHead(String cidString) async { final cid = CID.fromString(cidString); // throws on garbage: catch it final changed = await crdt.merge(cid); // false: already included or unfetchable if (changed) refreshUi();}onRemoteUpdateProcessed (constructor argument) fires only when a broadcast
received through the subscription changed the roots; it does not fire for
your own merge calls.
A custom payload
Section titled “A custom payload”A payload must be a state-based CRDT: merge commutative, associative and
idempotent, returning the concrete type, with canonical bytes.
import 'dart:convert';
/// Grow-only max register.class MaxInt implements CRDTPayload<int> { @override final int value; MaxInt(this.value);
factory MaxInt.fromBytes(Uint8List b) => MaxInt(int.parse(utf8.decode(b)));
@override MaxInt merge(CRDTPayload<int> other) => MaxInt(value >= other.value ? value : other.value);
@override Uint8List toCanonicalBytes() => Uint8List.fromList(utf8.encode('$value'));
@override String toString() => 'MaxInt($value)';}
final crdt = MerkleCRDT<int, MaxInt>(dagSyncer: MemorySyncer<MaxInt>(), broadcaster: LocalBus());Serialising nodes for a real DAGSyncer
Section titled “Serialising nodes for a real DAGSyncer”final bytes = node.toBytes(); // varint(len) CID bytes, then the hashed body
// On receipt, always decode with verification and the CID you asked for.final MerkleNode<GSet<String>> received = MerkleNode.fromBytes<GSet<String>, Set<String>>( bytes, GSet<String>.fromCanonicalBytes, expectedCid: requestedCid,); // throws InvalidNodeException on tamper, truncation, trailing or non-canonical bytesA disk or network syncer sketch:
class BytesSyncer implements DAGSyncer<GSet<String>> { BytesSyncer(this.load, this.save); final Future<Uint8List?> Function(CID) load; final Future<void> Function(CID, Uint8List) save;
@override Future<MerkleNode<GSet<String>>?> get(CID cid) async { final bytes = await load(cid); if (bytes == null) return null; try { return MerkleNode.fromBytes<GSet<String>, Set<String>>( bytes, GSet<String>.fromCanonicalBytes, expectedCid: cid); } on InvalidNodeException { return null; // treat a bad block as missing } }
@override Future<void> put(MerkleNode<GSet<String>> node) => save(node.cid, node.toBytes());}Persisting and restoring heads
Section titled “Persisting and restoring heads”Roots live only in memory. Store crdt.roots (as strings) yourself and
replay them after a restart; merge on an empty replica adopts the head:
await prefs.setStringList('heads', crdt.roots.map((c) => '$c').toList());// ... after restart, with a DAGSyncer over the same store:for (final s in prefs.getStringList('heads') ?? const []) { await crdt.merge(CID.fromString(s));}Merkle-Clock only
Section titled “Merkle-Clock only”MerkleClock<T> is the DAG without CRDT state: an event log whose heads you
merge.
final clock = MerkleClock<GSet<String>>(dagSyncer: MemorySyncer<GSet<String>>());final e1 = await clock.addNode(GSet<String>.fromList(['event-1']));final changed = await clock.merge(remoteHeadCid); // boolfinal seen = await clock.isIncluded(e1);Key concepts / API map
Section titled “Key concepts / API map”MerkleCRDT<V, P extends CRDTPayload<V>>: replica.add(P) -> Future<CID>,getState() -> Future<P?>,merge(CID) -> Future<bool>,close(),roots, plus the inherited DAG queries below. Constructor:dagSyncer,broadcaster,onRemoteUpdateProcessed,subscribe = true,maxCacheSize = 1000,maxTraversalNodes = 10000,maxMissingNodes = 8.MerkleClock<T>:addNode(T) -> Future<CID>,merge(CID) -> Future<bool>; same limits as above, no broadcaster.- Inherited from the (unexported)
AbstractMerkleDAG<T>:roots(copy),getNode(CID)(cache then syncer),isIncluded(CID),isDescendantOf(desc, ancestor)(async, fetches),isDescendant(desc, ancestor)(sync, cache only),isCurrentDAGIncludedIn(CID),mergeHead(CID),setRootsForTesting(Set<CID>). MerkleNode<T>:cid,payload,children;MerkleNode.create(payload, children);MerkleNode.computeCid(payload, children);hasValidCid;toBytes();MerkleNode.fromBytes<P, V>(bytes, factory, {expectedCid}). The plain constructor does not check the CID.InvalidNodeException(extendsFormatException): thrown byfromBytes.CRDTPayload<T>:value,merge(other),toCanonicalBytes(),toString().GSet<T>: grow-only set.GSet(),GSet.fromList,GSet.fromJson,GSet.fromCanonicalBytes(String elements only),add,contains,length,isEmpty,isNotEmpty,elements,value,toList,toJson. It is the only payload shipped.DAGSyncer<T>:get(CID) -> Future<MerkleNode<T>?>,put(MerkleNode<T>).Broadcaster:broadcast(String cidString),subscribe() -> Stream<String>.- Constants:
codecDagPb(0x70),codecRaw(0x55),mhSha2_256(0x12).
Gotchas
Section titled “Gotchas”- The README and example use mocks you cannot import.
MockDAGSyncerandMockBroadcasterare intest/(andexample/imports../test/merkledag_test.mocks.dart). Write your own in-memory implementations as above. CIDcomes fromdcid, notmerkledag. Adddcidto your pubspec and importpackage:dcid/dcid.dartto name, parse or compare CIDs.- State-based only. Every node carries the full merged state, and
getState()merges only the current roots’ payloads, never the history. Op-based or delta payloads that need replaying from genesis give wrong results.add()takes a delta and merges it into the state for you. mergemust be a real CRDT join. Commutative, associative, idempotent, and must return the concreteP(addandgetStatecast withas P; a wrong return type is a runtimeTypeError). “Last writer wins” by wall clock is not convergent unless ties break deterministically.toCanonicalBytesdecides the CID. Equal states must give identical bytes: sort collections, fix encodings, no maps in insertion order, no timestamps unless they are part of the state.fromBytesre-encodes the decoded payload and rejects it if the bytes differ, so the factory must be an exact inverse.GSet.fromCanonicalBytesonly supportsStringelements; otherTthrowsUnsupportedError.GSetsorts and encodes elements bytoString(), so elements whose strings collide (1and'1') are not distinguishable on the wire.GSet.hashCodeis the inner set’s identity hash and disagrees with==; do not useGSets as map keys or set members.- 2.0.0 changed every CID. The hashed body is now length-prefixed
payload plus sorted, length-prefixed child CIDs. Nodes or stores written by
1.x do not load and must be rebuilt; replicas on 1.x and 2.x cannot sync.
Old docs that say
merkledag: ^1.0.0are stale. - Not IPFS dag-pb, despite the codec. CIDs are CIDv1, sha2-256, codec 0x70 (dag-pb), but the body is this package’s own varint framing, not a dag-pb protobuf. Do not hand these blocks to IPFS or Bitswap peers expecting to decode them as dag-pb; they can be stored and moved as opaque bytes only.
- A head that cannot be fetched is dropped, not retried.
mergereturnsfalsewhen the remote node is unavailable, and the subscribed handler silently swallows every error (bad CID string, syncer exception). Make yourDAGSyncer.getwait or retry for remote nodes, or usesubscribe: falseand retry yourself; a later broadcast of a newer head also recovers it. - Your
DAGSyncermust persist what it fetched.mergeonly callsget; nodes fetched from peers are neverputlocally. They sit in an LRU cache ofmaxCacheSizenodes. Ifgetlater cannot return a root’s node,getState()skips it (or returnsnullif it is the first root), and the nextadd()folds that partial state into the new single head, losing the missing data from view. Backgetwith a store that keeps every node it has served. - Bounded traversal leaves extra roots. Merges walk at most
maxTraversalNodesnodes and stop aftermaxMissingNodes(default 8) unavailable ones. When cut short, the remote head is kept beside the local roots. State stays correct; the nextadd()folds them into one. Several roots after a merge is normal, not an error. - Roots are not persisted. A new
MerkleCRDTstarts empty (getState()returnsnull). Saverootsand replay them withmergeon start-up. Do not usesetRootsForTestingin production: it skips all checks. getNoderejects mismatched nodes. A node the syncer returns under a different CID is treated as missing. The plainMerkleNode(...)constructor does not compute the CID; useMerkleNode.create, or checkhasValidCid.- No-op adds do not broadcast. Adding an element already in the state
returns the current head and announces nothing. Peers that missed the
earlier broadcast will not learn it from a repeat; re-broadcast
crdt.rootsperiodically from your own code if delivery is lossy. addthrows ifputthrows, and roots stay unchanged. Broadcast failures also surface fromadd, after the node is already the local head; retry the broadcast, not theadd.MerkleClockwith non-CRDTPayloadpayloads hashestoString(), andfromBytesonly decodesCRDTPayloadtypes. Use aCRDTPayloadif the clock’s nodes must travel as bytes.- Call
close()when a replica is discarded, or its broadcast subscription keeps it alive and merging.
Testing
Section titled “Testing”- Use in-memory
DAGSyncer/Broadcasterclasses like those above. Sharing one syncer between replicas models a perfect network; give each replica its own map and copy nodes across to model partitions. - Broadcast handling is asynchronous. After
broadcast, awaitpumpEventQueue()(frompackage:test) orFuture.delayed(Duration.zero)before asserting onrootsor state. - Prefer
subscribe: falseplus explicitawait crdt.merge(cid)for deterministic tests;merge’sbooltells you whether roots changed. - To simulate failures, write a syncer that throws, returns
null, or hangs on unknown CIDs (seeCountingSyncerintest/hardening_test.dart, which also countsgetcalls to assert traversal cost). Put.timeout(...)on awaits that touch a hanging syncer. - Assert convergence on
(await crdt.getState())!.value, not onroots: equal states can sit under different root sets until the nextadd. - Round-trip
toBytes/fromByteswithexpectedCidfor every custom payload, and check that a tampered byte throwsInvalidNodeException. - Run the package’s own suite with
dart testin the repo.
Red lines
Section titled “Red lines”- Never deserialise untrusted node bytes without
MerkleNode.fromBytes(andexpectedCidwhen you asked for a specific CID). Never build nodes from received parts with the uncheckedMerkleNode(...)constructor. - Never merge a CID from the network without catching parse and syncer
errors; never let a remote peer drive unbounded traversal by raising
maxTraversalNodesormaxMissingNodeswithout a reason. - Never change a payload’s
toCanonicalBytesencoding in a deployed system: every CID changes and existing replicas and stores stop matching. - Never mix 1.x and 2.x replicas or stores.
- Never implement
mergewith randomness, wall-clock reads or I/O. - Never leave a
MerkleCRDTrunning withoutclose()when you are done.
Reference
Section titled “Reference”- Docs: https://cryptopeer.net/libp2p/merkle-crdt/
- Replication over libp2p: https://cryptopeer.net/libp2p/merkle-crdt-sync/
(skill
merkle-crdt-sync) - Repo: https://github.com/stephanfeb/merkle-crdt (package
merkledag, seeDEVELOPER-GUIDE.md,CHANGELOG.md,test/hardening_test.dart) - Paper: https://arxiv.org/abs/2004.00107