shield-bridge-sdk
Version:
228 lines • 12.4 kB
JavaScript
/**
* Incremental shielded-balance cache (the DECRYPT layer — "v2").
*
* v1 (saplingDiffCache) made the FETCH incremental but the viewer still trial-decrypts EVERY
* commitment and re-checks EVERY matched note's spent status on each read (O(pool size)). This
* layer makes the DECRYPT incremental too: it caches the account's matched notes and only
* decrypts commitments added since the last scan, then re-checks spent status using octez.js's
* own `isSpent` (no nullifier re-implementation — the audited logic is reused verbatim).
*
* Safety model (this is a money path, so it is deliberately conservative):
* - PER-ACCOUNT + private: the cache holds DECRYPTED notes, so it is keyed by a non-reversible
* fingerprint of the viewing key. Evict on account forget (clearForViewingKey).
* - Reorg-safe: decrypted notes are persisted ONLY for the finalized prefix (head~2, immutable
* under Tenderbake). The unconfirmed tail is decrypted fresh every scan and never persisted;
* a spent flag is persisted only when caused by a FINALIZED nullifier. So a reorg of a recent
* block self-heals, and a just-received note shows immediately.
* - Self-checking: every Nth scan (and on every cold build) the incremental result is compared
* against a full stock `getBalance()`. Any mismatch invalidates the cache, returns the stock
* value, and warns — so a bug here can never silently surface a wrong balance for long.
* - Foolproof: any thrown error falls back to the full stock `getBalance()`.
*/
/* eslint-disable no-await-in-loop, no-restricted-syntax, no-continue, no-bitwise --
The decrypt and isSpent loops are SEQUENTIAL BY DESIGN: each calls the single-threaded sapling
wasm, and octez.js's own getBalance() iterates them serially. Parallelizing (Promise.all) would
make re-entrant wasm calls. The loops are bounded by the account's matched-note count (not the
pool size), so this stays incremental. */
import BigNumber from 'bignumber.js';
import { secretBox, openSecretBox } from '@stablelib/nacl';
import { randomBytes } from '@stablelib/random';
import { blake2b } from 'blakejs';
import { syncPoolDiff, } from './saplingDiffCache.js';
const DEFAULT_SELF_CHECK_EVERY = 8;
const BAL_KEY_PREFIX = 'bal:';
// Per-account self-check cadence counter (module memory — controls sampling only, not correctness).
const selfCheckCounters = new Map();
/** Mirrors octez.js's convertValueToBigNumber: the value bytes parsed as a base-16 integer. */
const toValue = (value) => new BigNumber(Buffer.from(value).toString('hex'), 16);
const toHex = (bytes) => Buffer.from(bytes).toString('hex');
const fromHex = (hex) => Buffer.from(hex, 'hex');
/** cyrb53: a fast, dependency-free, non-cryptographic 53-bit hash. Used only to namespace the
* cache by viewing key WITHOUT writing the (sensitive) viewing key into a storage key. */
function cyrb53(str) {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < str.length; i += 1) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 =
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 =
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
}
/** Stable fingerprint of a full viewing key — non-reversible, so it never exposes the key. */
export function viewingKeyFingerprint(fvkHex) {
return cyrb53(fvkHex);
}
function balanceKey(fvkHex, rpcUrl, target) {
let host;
try {
host = new URL(rpcUrl).host;
}
catch {
host = rpcUrl;
}
return `${BAL_KEY_PREFIX}${viewingKeyFingerprint(fvkHex)}:${host}:${target.kind}:${target.id}`;
}
// ---------------------------------------------------------------------------
// At-rest encryption
// ---------------------------------------------------------------------------
// Cached notes are decrypted balances, so they are NEVER written in the clear. Each entry is
// encrypted under a key derived from the viewing key (the same secret that already decrypts the
// notes on-chain), using XSalsa20-Poly1305 (authenticated) and a random nonce. All pure
// JS/wasm — no crypto.subtle — so it works in non-secure contexts (e.g. a LAN/HTTP origin)
// too. Effect: at rest the cache is opaque ciphertext only the viewing-key holder can read.
// A locked account (its viewing key encrypted behind the password) can't be read off disk; a
// session-only account leaves only undecryptable ciphertext once its key is gone. This is an
// AT-REST defense only — while the app is unlocked the key + balances are in memory as usual.
const CACHE_KEY_DOMAIN = Buffer.from('shield-bridge/balance-cache/v1');
/** 32-byte symmetric key bound to the viewing key (domain-separated keyed blake2b). */
function deriveCacheKey(fvkHex) {
return blake2b(Buffer.from(fvkHex, 'utf8'), CACHE_KEY_DOMAIN, 32);
}
function encryptAccount(acct, fvkHex) {
const key = deriveCacheKey(fvkHex);
const nonce = randomBytes(24);
const box = secretBox(key, nonce, Buffer.from(JSON.stringify(acct), 'utf8'));
return {
v: 1,
n: Buffer.from(nonce).toString('hex'),
c: Buffer.from(box).toString('hex'),
};
}
/** Decrypt a stored envelope, or return null (→ rebuild) on a wrong key, tamper, or bad shape.
* Exported for inspection/tests; the SDK package does not re-export it. */
export function decryptAccount(stored, fvkHex) {
const env = stored;
if (!env ||
env.v !== 1 ||
typeof env.n !== 'string' ||
typeof env.c !== 'string')
return null;
const opened = openSecretBox(deriveCacheKey(fvkHex), Buffer.from(env.n, 'hex'), Buffer.from(env.c, 'hex'));
if (!opened)
return null; // wrong key / tampered / different account → treat as a cache miss
try {
return JSON.parse(Buffer.from(opened).toString('utf8'));
}
catch {
return null;
}
}
/**
* Incremental shielded balance (base units). Falls back to a full stock `getBalance()` on any
* error. The same `store` backs v1's diff cache (namespaced keys never collide).
*/
export async function incrementalBalance(opts) {
const { store, rpcUrl, target, viewer, fvkHex } = opts;
const everyN = opts.selfCheckEvery ?? DEFAULT_SELF_CHECK_EVERY;
const key = balanceKey(fvkHex, rpcUrl, target);
try {
// 1. Incremental fetch → finalized prefix (persisted by v1) + fresh unconfirmed tail.
const split = await syncPoolDiff(store, rpcUrl, target);
const finalizedCount = split.finalizedCommitments.length;
// 2. Load + decrypt the per-account note cache; reset if it's somehow ahead of the chain
// (append-only means this can't legitimately happen — defensive). A failed decrypt
// (wrong key / tamper) yields null here and rebuilds from scratch.
const loaded = decryptAccount(await store.get(key), fvkHex);
let acct = loaded &&
Array.isArray(loaded.notes) &&
typeof loaded.decryptCursor === 'number'
? loaded
: { decryptCursor: 0, notes: [] };
if (acct.decryptCursor > finalizedCount)
acct = { decryptCursor: 0, notes: [] };
const priorCursor = acct.decryptCursor; // 0 ⇒ this is a cold full build
// 3. Decrypt ONLY new finalized commitments; cache the matches.
for (let i = acct.decryptCursor; i < finalizedCount; i += 1) {
const d = await viewer.decryptCiphertextAsReceiver(split.finalizedCommitments[i]);
if (d) {
acct.notes.push({
position: i,
valueStr: toValue(d.value).toString(),
addressHex: toHex(d.paymentAddress),
rcmHex: toHex(d.randomCommitmentTrapdoor),
spent: false,
});
}
}
acct.decryptCursor = finalizedCount;
// 4. Finalized spent transitions (immutable → persisted): re-check unspent notes against the
// finalized nullifier set only.
for (const n of acct.notes) {
if (!n.spent) {
const spent = await viewer.isSpent(fromHex(n.addressHex), n.valueStr, fromHex(n.rcmHex), n.position, split.finalizedNullifiers);
if (spent)
n.spent = true;
}
}
await store.set(key, encryptAccount(acct, fvkHex));
// 5. Balance from finalized notes, applying a NON-persisted tail-spent overlay (a note spent
// only in the unconfirmed tail is excluded this scan but not marked spent on disk).
let balance = new BigNumber(0);
const hasTailNullifiers = split.tailNullifiers.length > 0;
for (const n of acct.notes) {
if (n.spent)
continue;
const tailSpent = hasTailNullifiers &&
(await viewer.isSpent(fromHex(n.addressHex), n.valueStr, fromHex(n.rcmHex), n.position, split.tailNullifiers));
if (!tailSpent)
balance = balance.plus(new BigNumber(n.valueStr));
}
// 6. Tail notes: decrypt fresh (never persisted), add the unspent ones.
const allNullifiers = split.finalizedNullifiers.concat(split.tailNullifiers);
for (let j = 0; j < split.tailCommitments.length; j += 1) {
const d = await viewer.decryptCiphertextAsReceiver(split.tailCommitments[j]);
if (!d)
continue;
const position = finalizedCount + j;
const valueStr = toValue(d.value).toString();
const spent = await viewer.isSpent(d.paymentAddress, valueStr, d.randomCommitmentTrapdoor, position, allNullifiers);
if (!spent)
balance = balance.plus(new BigNumber(valueStr));
}
// 7. Self-check on WARM scans, sampled ~1/everyN. A cold full build (priorCursor === 0) is by
// construction a full scan equal to stock, so re-running getBalance there would only double
// the cold cost; the periodic warm check guards against incremental drift. Any mismatch ⇒ a
// bug here; invalidate the cache + return the trusted stock value.
//
// The per-key counter is seeded with a RANDOM phase, not 0. This module's state lives in the
// worker, which is recreated on every page load — so a 0 seed made `count % everyN === 0`
// fire on the FIRST warm scan of EVERY asset each session: a synchronized stampede that
// re-fetched the diff AND forced a full stock re-decrypt (O(pool)) for the whole portfolio on
// every load — the very cost this cache exists to avoid. A random phase keeps the same
// long-run sampling rate while firing on any single scan (incl. the first) with prob ~1/everyN.
let count = selfCheckCounters.get(key);
if (count === undefined)
count = Math.floor(Math.random() * everyN);
selfCheckCounters.set(key, count + 1);
if (priorCursor > 0 && count % everyN === 0) {
const stock = await viewer.getBalance();
if (!stock.eq(balance)) {
// eslint-disable-next-line no-console
console.warn(`[ShieldBridgeSDK] balance-cache self-check mismatch (incremental=${balance.toString()}, stock=${stock.toString()}); invalidating cache and using stock.`);
await store.delete(key);
return stock;
}
}
return balance;
}
catch (err) {
// Foolproof: never let the optimization break or mis-report a balance.
// eslint-disable-next-line no-console
console.warn('[ShieldBridgeSDK] balance cache failed; falling back to full scan:', err);
return viewer.getBalance();
}
}
/** Evict every cached balance for a viewing key (call on account forget — removes decrypted data). */
export async function clearForViewingKey(store, fvkHex) {
if (typeof store.deleteByPrefix === 'function') {
await store.deleteByPrefix(`${BAL_KEY_PREFIX}${viewingKeyFingerprint(fvkHex)}:`);
}
}
//# sourceMappingURL=saplingBalanceCache.js.map