Pluggable note storage adapters.
import {
createMemoryNoteAdapter,
createIndexedDbNoteAdapter,
type NoteStorageAdapter,
type StoredNote,
} from "@zkscatter/sdk/notes";A note is the secret preimage of a commitment: owner secret, token, amount, salt, and Baby Jubjub pubkey. Lose the note, lose the funds. The storage adapters give you a typed persistence layer with hex ↔ bigint serialization at the boundary.
Interface
interface NoteStorageAdapter {
ready(): Promise<void>;
loadAll(): Promise<StoredNote[]>;
put(note: StoredNote): Promise<void>;
remove(id: string): Promise<void>;
clear(): Promise<void>;
}
interface StoredNote {
id: string; // your choice — usually the commitment hex
label: string; // human-friendly tag for the UI
symbol: string; // token symbol
amount: string; // display string (decimals already applied)
note: CommitmentNote; // bigint preimage
commitment: bigint; // Poseidon hash of the note
leafIndex: number; // position in the on-chain pool
txHash?: string;
chainId?: number;
createdAt: number;
}Memory adapter
const notes = createMemoryNoteAdapter();
await notes.ready();
await notes.put(stored);In-process — wiped on reload. Use for tests, SSR, and CLI tools.
IndexedDB adapter
const notes = createIndexedDbNoteAdapter({
dbName: "zkscatter",
storeName: "notes",
version: 1,
});
await notes.ready();
await notes.put(stored);
const all = await notes.loadAll();The adapter handles idle → ready setup, schema migrations, and
graceful degradation on browsers without IndexedDB (returns an empty
list rather than throwing).
Lifecycle
A typical browser app wires storage like this:
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createIndexedDbNoteAdapter, type StoredNote } from "@zkscatter/sdk/notes";
export function useNotes() {
const adapter = useMemo(() => createIndexedDbNoteAdapter(), []);
const [notes, setNotes] = useState<StoredNote[]>([]);
useEffect(() => {
let alive = true;
(async () => {
await adapter.ready();
const loaded = await adapter.loadAll();
if (alive) setNotes(loaded);
})();
return () => { alive = false };
}, [adapter]);
// Upsert: replace by id if it exists, otherwise append. Avoids
// duplicate rows when the same note is updated (e.g. leafIndex
// back-fill after CommitmentInserted lands).
const add = useCallback(async (n: StoredNote) => {
await adapter.put(n);
setNotes((prev) => {
const i = prev.findIndex((x) => x.id === n.id);
if (i < 0) return [...prev, n];
const next = prev.slice();
next[i] = n;
return next;
});
}, [adapter]);
const remove = useCallback(async (id: string) => {
await adapter.remove(id);
setNotes((prev) => prev.filter((n) => n.id !== id));
}, [adapter]);
return { notes, add, remove };
}Mobile / React Native
There’s no built-in adapter for AsyncStorage / SQLite — implement the
NoteStorageAdapter interface against whatever native storage your app
uses. The mobile reference app in mobile/src/services/NoteStorageService.ts
is a complete example.
Recovery from seed
If a user loses their notes, you can rebuild by:
- Re-derive their EdDSA pubkey via
deriveEdDSAKey. - Stream
CommitmentInsertedevents withloadCommitmentInsertedHistory. - For each commitment, recompute
Poseidon(ownerSecret, token, amount, salt, pubKey)for plausible(token, amount, salt)candidates and compare.
In practice you keep a small “salt index” alongside notes in IndexedDB so recovery doesn’t require brute force.
Hardening
- Encrypt the IndexedDB blob if your threat model includes physical
device access — wrap
put/loadAllwith a passphrase-derived AES key. - Clear notes on account switch — leftover notes from a previous account are confusing at best, leakage at worst.