@saga-sync/client
v0.1.4
Published
Consumer library + CLI for saga-sync: fetch, verify, and stream published privacy-protocol state
Readme
@saga-sync/client
The consumer side of saga-sync — the reference implementation of privacy-protocol-state-distribution. Given a manifest URL it reconstructs a privacy protocol's event history by downloading the published static files and verifying every one against the manifest — no trust in the publisher beyond the manifest itself (and, optionally, its Ed25519 signature).
Install just this package to consume published state — it pulls in
@saga-sync/core and @noble/*, and nothing else: no viem, no
@google-cloud/storage, no scraper.
npm install @saga-sync/clientShips a library (@saga-sync/client) and a CLI (state-client). The library
entry is browser-safe — gzip via the web-standard DecompressionStream,
Uint8Array + TextDecoder instead of Buffer, no node: imports (a guard test
enforces it). The CLI and the optional disk cache are Node-only.
Quick start (library)
import { Client, HttpStore } from "@saga-sync/client";
// `source` is the Store the manifest + chunks live behind (a CDN/bucket URL).
const client = new Client({ source: new HttpStore("https://cdn.example/pp-state/") });
// List what's published.
for (const id of await client.listProtocols()) console.log(id);
// Stream one protocol's full event history in block order.
for await (const event of client.streamEvents("tornado-cash-1-eth-0.1")) {
// event: CanonicalEvent — { contractAddress, eventTopic, topics, data, blockNumber, logIndex }
handle(event);
}streamEvents returns an AsyncGenerator<CanonicalEvent>, not an array — you
consume it once with for await. That's a deliberate memory choice: events are
produced lazily and peak memory is bounded to roughly one decompressed chunk
(~10 MiB) × the fetch concurrency, independent of total history size, so you
can process a stream larger than RAM. It also means an early break never
downloads or verifies the chunks you didn't reach, and you start processing the
first chunk while later ones are still downloading. If you genuinely want the whole
array, collect it yourself:
const all: CanonicalEvent[] = [];
for await (const e of client.streamEvents(id)) all.push(e);Filtering
streamEvents(id, opts) accepts:
from/to— restrict to a block range ([from, to)); only overlapping chunks are fetched.addresses— keep only events from these contract addresses.eventTopics— keep only these eventtopic0s (event types).
addresses/eventTopics are validated against the manifest's trackedAddresses /
trackedEventTopics and throw if you ask for something the stream doesn't track
— a mismatched filter is a bug, not a silently-empty result.
for await (const e of client.streamEvents(id, {
from: "0x8b1d26",
eventTopics: ["0xa945e51e…"], // Deposit only
})) { … }Selecting a stream by address
Don't know (or want to hardcode) the stream id? Pass a selector — a contract
address, optionally with a chain-id guard — instead of an id. The client resolves
it against the manifest's trackedAddresses (and chainId) to exactly one stream
and streams it, implicitly filtered to that address:
for await (const e of client.streamEvents({ address: "0x12d66f87…", chainId: "0x1" })) {
handle(e);
}
// Or resolve without streaming:
const id = await client.resolveProtocolId({ address: "0x12d66f87…" });chainId is optional. A manifest is single-chain in practice (mainnet and Sepolia
are separate buckets), so it mostly guards against pointing at the wrong one.
Resolution throws if the address matches no stream, or more than one — a
selector must name exactly one stream (pass an explicit id to disambiguate).
Combining a selector with opts.addresses also throws (the address is the
selector).
Verification model
Everything the client serves is verified — cache hits included:
verifyDigest(meta, bytes)recomputes the sha256 of a chunk's uncompressed JSONL and compares it to the manifest entry; a mismatch throwsDigestMismatchError.verifyChunkEvents(meta, events)then enforces the canonical form the digest can't catch on its own — every event within the chunk's[from, to)range and strictly ascending by(blockNumber, logIndex); violations throwCanonicalFormError.- A missing file throws
ChunkNotFoundError, kept distinct from a digest mismatch so callers can tell "absent" from "tampered".
Manifest signatures (optional)
Supply a publisher public key and the client fetches index.json.sig and verifies
the Ed25519 signature over the raw index.json bytes before parsing — mandatory
once enabled (missing or mismatched signature throws). Because the manifest holds
every chunk's digest, one signature transitively authenticates the whole dataset.
const client = new Client({ source: new HttpStore(baseUrl), publicKey: "0x…" });Library API
Client—streamEvents(target, opts?)(merged sealed chunks then the hot head, sealed fetched with a bounded-concurrency sliding window and optionally cached to a localStore).targetis a protocol id or a{ address, chainId? }selector.resolveProtocolId(selector)resolves a selector to an id without streaming;listProtocols(prefix?)enumerates ids; plus manifest inspection helpers.loadManifest(store, key, { publicKey? })— one fetch; verifies the signature when a key is supplied. Re-uses core'sManifestso the shape is defined once.decodeAndVerify/fetchChunkFrom(store, meta)— the fetch→gunzip→verify→ parse→verify-canonical pipeline for a single chunk.verifyDigest/verifyChunkEventsand the error types above.
Reads go through core's Store seam (HttpStore for the network, an optional
local cache Store). Sealed chunks are safe to cache — immutable, content-
addressed, and re-verified on every read; the hot head is re-fetched every call and
never cached.
CLI (state-client)
Subcommands take a <manifest-url> (the manifest is read from <url>/index.json);
the query commands fetch only the manifest — no chunk downloads.
state-client <command> <manifest-url> [<protocol-id>] [options]
protocols <url> list every protocol + summary (alias: ls)
info <url> <id> range, size, metadata, hot head, gap/contiguity check
head <url> <id> latest covered block (alias: latest)
chunks <url> <id> list a protocol's chunks
stream <url> [<id>] download + verify + emit NDJSON
--json machine-readable output instead of human tables
--from-block <hex> info/chunks/stream: lower bound of the block range
--to-block <hex> info/chunks/stream: upper bound (exclusive)
--address <hex> stream: with an <id>, a repeatable post-decode filter;
without an <id>, a single --address selects the stream
--chain <hex> stream: chain-id guard when selecting a stream by --address
--event-topic <hex> stream: keep only these event topic0s (repeatable)
--since-block <hex> head: exit 3 if no block beyond this is covered
--hot chunks: include the mutable hot head
--cache-dir <path> stream: local cache of verified sealed chunks
--concurrency <n> stream: parallel chunk fetches (default 4)
--public-key <hex> require + verify the manifest's Ed25519 signatureStream by address instead of naming the id:
state-client stream https://cdn.example/pp-state/ --address 0x12d66f87… --chain 0x1stream emits NDJSON on stdout + a summary on stderr; the query commands print a
human table or, with --json, structured JSON. --public-key applies to all
commands and verifies index.json.sig before trusting the manifest.
Exit codes: 0 ok · 1 usage / fetch / not-found · 3 head --since-block found
nothing newer.
# serve some published chunks, then:
node packages/client/dist/cli.js info http://localhost:8080/ tornado-cash-1-eth-0.1
node packages/client/dist/cli.js stream http://localhost:8080/ tornado-cash-1-eth-0.1 \
--cache-dir ./client-cache > events.ndjsonModules
verify.ts—verifyDigest+verifyChunkEvents(both mandatory on every chunk).fetch.ts—decodeAndVerify+fetchChunkFrom; the per-chunk pipeline.manifest.ts—loadManifest+ pureselectSealedChunks/selectHotHeadrange-overlap helpers + signature check.client.ts— theClientclass and the merged streaming logic.format.ts—humanBytes+table, the CLI's rendering helpers.cli.ts— thestate-cliententry point.index.ts— the browser-safe library barrel.
