shield-bridge-sdk
Version:
326 lines • 14.7 kB
JavaScript
/* eslint-disable max-classes-per-file -- two small, closely-related store impls (memory + IndexedDB) belong together */
/**
* Incremental sapling-diff cache (the FETCH layer).
*
* octez.js's SaplingTransactionViewer re-fetches a pool's ENTIRE `single_sapling_get_diff`
* on every balance/transaction read (O(pool size), no offset, no cache). A pool's diff only
* ever grows (commitment/nullifier trees are append-only), so this cost climbs without bound
* and is paid on every scan, for every account, for every asset viewed.
*
* This module makes that fetch incremental WITHOUT touching the audited decrypt/spend logic:
* a caching read-provider wraps the real RPC adapter and reconstructs the head diff from a
* persisted FINALIZED prefix plus a freshly-fetched UNCONFIRMED tail. The viewer still runs
* its normal `getBalance()` over the (identical) reconstructed diff, so there is zero risk of
* a wrong balance — the only thing that changes is how many bytes cross the wire.
*
* today: get_diff(head) → 525 KB every scan (XTZ pool, and growing)
* incremental: get_diff(head~2, offset=cached) → ~125 B when nothing finalized since last
* + get_diff(head, offset=finalized) → only the unconfirmed tail (≤2 blocks)
*
* Cached data is PUBLIC pool state (encrypted commitments + nullifiers) keyed by
* `(rpc host, set contract / sapling id)` — account-agnostic, so it is shared across every
* shielded account on the device and carries no decrypted/private material.
*
* Reorg safety: Tezos (Tenderbake) finalizes a block after 2 confirmations, so the diff at
* `head~2` is immutable — safe to persist. The unconfirmed tail (`head~2`..`head`) is fetched
* fresh every scan and NEVER persisted, so a reorg of a recent block self-heals on the next
* scan, and an account's own just-submitted note still shows immediately (it's in the tail).
*
* Foolproof by construction: any error (offset unsupported, short chain, store failure, …)
* falls back to the wrapped adapter's full fetch — never a wrong or missing result.
*/
/** Tenderbake finality: a block is final after this many confirmations. */
const CONFIRMATIONS = 2;
// ---------------------------------------------------------------------------
// Stores
// ---------------------------------------------------------------------------
/** In-memory store (process lifetime). For Node/Lambda/tests; browsers should use IndexedDB. */
export class MemoryDiffStore {
constructor() {
this.map = new Map();
}
get(key) {
return this.map.get(key) ?? null;
}
set(key, value) {
this.map.set(key, value);
}
delete(key) {
this.map.delete(key);
}
deleteByPrefix(prefix) {
[...this.map.keys()]
.filter((k) => k.startsWith(prefix))
.forEach((k) => this.map.delete(k));
}
}
/** IndexedDB-backed store — works on the main thread AND inside Web Workers (same origin DB). */
export class IndexedDbDiffStore {
constructor() {
this.dbName = 'shield-bridge-sapling-cache';
this.storeName = 'diffs';
this.dbPromise = null;
}
open() {
if (this.dbPromise)
return this.dbPromise;
this.dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(this.dbName, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return this.dbPromise;
}
async get(key) {
const db = await this.open();
return new Promise((resolve, reject) => {
const req = db
.transaction(this.storeName, 'readonly')
.objectStore(this.storeName)
.get(key);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
}
async set(key, value) {
const db = await this.open();
await new Promise((resolve, reject) => {
const req = db
.transaction(this.storeName, 'readwrite')
.objectStore(this.storeName)
.put(value, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async delete(key) {
const db = await this.open();
await new Promise((resolve, reject) => {
const req = db
.transaction(this.storeName, 'readwrite')
.objectStore(this.storeName)
.delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async deleteByPrefix(prefix) {
const db = await this.open();
await new Promise((resolve, reject) => {
const objectStore = db
.transaction(this.storeName, 'readwrite')
.objectStore(this.storeName);
const range = IDBKeyRange.bound(prefix, `${prefix}`);
const req = objectStore.openCursor(range);
req.onsuccess = () => {
const cursor = req.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
else {
resolve();
}
};
req.onerror = () => reject(req.error);
});
}
}
function indexedDbAvailable() {
try {
// eslint-disable-next-line no-restricted-globals
return typeof indexedDB !== 'undefined' && indexedDB !== null;
}
catch {
return false;
}
}
/** The default store for the current runtime: IndexedDB if available (browser / web worker), else none. */
export function createDefaultDiffStore() {
return indexedDbAvailable() ? new IndexedDbDiffStore() : null;
}
function hostOf(rpcUrl) {
try {
return new URL(rpcUrl).host;
}
catch {
return rpcUrl;
}
}
function cacheKey(rpcUrl, target) {
return `${hostOf(rpcUrl)}|${target.kind}:${target.id}`;
}
// ---------------------------------------------------------------------------
// Rate-limit resilience
// ---------------------------------------------------------------------------
// A public RPC (e.g. rpc.tzkt.io) rate-limits bursts, and a portfolio scan fans out one diff
// fetch per asset. Without backoff a 429 throws straight through to the caller, whose own retry
// (e.g. React Query) then re-runs the WHOLE scan — amplifying the very load that caused the
// limit. Instead, treat 429/502/503/504 and transient network errors as retryable here: wait out
// the server's Retry-After (or exponential backoff with jitter) and try again, so the fetch
// stream self-throttles and recovers transparently as the asset list grows. Attempts are bounded,
// so a persistent limit still surfaces as an error — which the caching read-provider catches and
// falls back to a full fetch, never a wrong result.
const RETRYABLE_DIFF_STATUS = new Set([429, 502, 503, 504]);
const MAX_DIFF_FETCH_ATTEMPTS = 4;
const DIFF_BACKOFF_BASE_MS = 500;
const DIFF_BACKOFF_CAP_MS = 8000;
const sleep = (ms) => new Promise((resolve) => {
setTimeout(resolve, ms);
});
/** Parse a Retry-After header (delta-seconds or HTTP-date) to ms, clamped to the cap; undefined if absent/unparseable. */
function retryAfterMs(header) {
if (!header)
return undefined;
const secs = Number(header);
if (Number.isFinite(secs))
return Math.min(Math.max(0, secs * 1000), DIFF_BACKOFF_CAP_MS);
const when = Date.parse(header);
if (!Number.isNaN(when))
return Math.min(Math.max(0, when - Date.now()), DIFF_BACKOFF_CAP_MS);
return undefined;
}
/** Backoff for a 0-based attempt: the server's hint if given, else exponential with full jitter. */
function diffBackoffMs(attempt, hint) {
if (hint !== undefined)
return hint;
const expo = Math.min(DIFF_BACKOFF_BASE_MS * 2 ** attempt, DIFF_BACKOFF_CAP_MS);
return Math.floor(expo / 2 + Math.random() * (expo / 2));
}
/** Raw offset get_diff — octez.js's client never passes offsets, so build the URL directly.
* Retries 429/5xx + transient network errors with Retry-After-aware backoff (see above). */
async function fetchDiffAtOffset(rpcUrl, target, block, offC, offN) {
const base = rpcUrl.replace(/\/+$/, '');
const path = target.kind === 'contract'
? `/chains/main/blocks/${block}/context/contracts/${target.id}/single_sapling_get_diff`
: `/chains/main/blocks/${block}/context/sapling/${target.id}/get_diff`;
const url = `${base}${path}?offset_commitment=${offC}&offset_nullifier=${offN}`;
let lastError = new Error(`get_diff failed for ${block}`);
/* eslint-disable no-await-in-loop -- retries are intentionally sequential: await the backoff before the next attempt */
for (let attempt = 0; attempt < MAX_DIFF_FETCH_ATTEMPTS; attempt += 1) {
const isLast = attempt === MAX_DIFF_FETCH_ATTEMPTS - 1;
let res;
try {
res = await fetch(url);
}
catch (err) {
// Network/transport error — transient; back off and retry unless this was the last attempt.
lastError = err instanceof Error ? err : new Error(String(err));
if (isLast)
break;
await sleep(diffBackoffMs(attempt));
}
if (res) {
if (res.ok)
return (await res.json());
lastError = new Error(`get_diff ${res.status} ${res.statusText} for ${block}`);
if (!RETRYABLE_DIFF_STATUS.has(res.status) || isLast)
throw lastError;
await sleep(diffBackoffMs(attempt, retryAfterMs(res.headers.get('retry-after'))));
}
}
/* eslint-enable no-await-in-loop */
throw lastError;
}
/**
* Sync a pool's diff incrementally: extend the persisted finalized prefix by its offset, then
* fetch the fresh unconfirmed tail. Persists ONLY the finalized prefix. The finalized fetch
* transfers ~nothing in steady state; the tail is ≤2 blocks. This is the shared core used both
* by the v1 read-provider (which merges the two) and the v2 balance cache (which needs them
* separately so it can persist decrypted notes for the finalized prefix only).
*/
export async function syncPoolDiff(store, rpcUrl, target) {
const key = cacheKey(rpcUrl, target);
const cached = (await store.get(key)) ?? {
offC: 0,
offN: 0,
commitments: [],
nullifiers: [],
};
// 1. Extend the FINALIZED prefix. head~2 is immutable under Tenderbake finality, so this can
// be persisted and never rolled back. In steady state this returns ~nothing.
const fin = await fetchDiffAtOffset(rpcUrl, target, `head~${CONFIRMATIONS}`, cached.offC, cached.offN);
const finalizedCommitments = cached.commitments.concat(fin.commitments_and_ciphertexts);
const finalizedNullifiers = cached.nullifiers.concat(fin.nullifiers);
await store.set(key, {
offC: finalizedCommitments.length,
offN: finalizedNullifiers.length,
commitments: finalizedCommitments,
nullifiers: finalizedNullifiers,
});
// 2. Fetch the UNCONFIRMED tail (head~2..head). NEVER persisted: a reorg of these blocks
// self-heals on the next scan, and the account's own just-submitted note is here, so it
// shows immediately.
const tail = await fetchDiffAtOffset(rpcUrl, target, 'head', finalizedCommitments.length, finalizedNullifiers.length);
return {
root: tail.root,
finalizedCommitments,
finalizedNullifiers,
tailCommitments: tail.commitments_and_ciphertexts,
tailNullifiers: tail.nullifiers,
};
}
/** v1 helper: the full head diff (finalized + tail merged) — identical to a full get_diff(head). */
async function incrementalDiff(store, rpcUrl, target) {
const s = await syncPoolDiff(store, rpcUrl, target);
return {
root: s.root,
commitments_and_ciphertexts: s.finalizedCommitments.concat(s.tailCommitments),
nullifiers: s.finalizedNullifiers.concat(s.tailNullifiers),
};
}
// ---------------------------------------------------------------------------
// Caching read provider
// ---------------------------------------------------------------------------
/**
* Wrap an octez.js read provider so the viewer's `get_diff` calls at `head` are served
* incrementally. Every other provider method, and any non-`head` block read, delegates to the
* real adapter unchanged. Any failure in the incremental path also delegates (full fetch), so
* the wrapper can only ever make reads cheaper — never wrong, never failed.
*/
export function makeCachingReadProvider(adapter, rpcUrl, store) {
const isHead = (block) => block === 'head' || block === undefined;
return new Proxy(adapter, {
get(target, prop, receiver) {
if (prop === 'getSaplingDiffByContract') {
return async (contract, block) => {
if (isHead(block)) {
try {
return await incrementalDiff(store, rpcUrl, {
kind: 'contract',
id: contract,
});
}
catch {
/* fall through to a full fetch — foolproof */
}
}
return target.getSaplingDiffByContract.call(target, contract, block);
};
}
if (prop === 'getSaplingDiffById') {
return async (query, block) => {
if (isHead(block)) {
try {
const id = String(query?.id ?? query);
return await incrementalDiff(store, rpcUrl, { kind: 'id', id });
}
catch {
/* fall through */
}
}
return target.getSaplingDiffById.call(target, query, block);
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
}
//# sourceMappingURL=saplingDiffCache.js.map