UNPKG

shield-bridge-sdk

Version:
326 lines 14.7 kB
/* 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