shield-bridge-sdk
Version:
530 lines • 21.9 kB
JavaScript
/**
* Core sapling worker functions — no Comlink, no threading.
*
* This module contains all the sapling operations (key management, proof
* generation, balance/transaction queries) without any Comlink or Web Worker
* dependency. It is the single source of truth that both:
*
* - `worker.ts` wraps with Comlink.expose() for browser Web Workers / Node.js
* worker_threads, and
* - `index.ts` can import directly for thread-free ("direct execution") mode
* (e.g. AWS Lambda, CLI tools).
*
* The module maintains singleton state (spending key, toolkit, etc.) which is
* safe because each execution context (Web Worker, worker_thread, or the main
* thread in direct mode) loads its own copy.
*/
import { RpcReadAdapter } from '@tezos-x/octez.js';
import { SaplingToolkit, InMemorySpendingKey, InMemoryViewingKey, SaplingTransactionViewer, } from '@tezos-x/octez.js-sapling';
import { RpcClient } from '@tezos-x/octez.js-rpc';
import { PrefixV2, b58Encode, bytesToString } from '@tezos-x/octez.js-utils';
import * as sapling from '@airgap/sapling-wasm';
import * as bip39 from 'bip39';
import BigNumber from 'bignumber.js';
import { makeCachingReadProvider, createDefaultDiffStore, } from './saplingDiffCache.js';
import { incrementalBalance, clearForViewingKey, } from './saplingBalanceCache.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SECRET_KEY_METHOD = 'secretKey';
const MNEMONIC_METHOD = 'mnemonic';
const VIEWING_KEY_METHOD = 'viewingKey';
// ---------------------------------------------------------------------------
// Sapling parameter lazy-loading
// ---------------------------------------------------------------------------
/**
* Custom URLs for sapling parameters (set by consumer via setSaplingParamsUrl).
* When `null`, default resolution is used.
*/
let saplingParamsUrls = null;
/** Whether sapling parameters have been initialized */
let saplingParamsInitialized = false;
/** In-flight loading promise (prevents duplicate loading) */
let saplingParamsLoading = null;
/**
* Resolve the default sapling params URLs based on the runtime environment.
* - Browser web worker: relative to the worker script URL
* - Node.js: relative to this file on disk
*/
function getDefaultParamsUrls() {
/* eslint-disable no-restricted-globals */
if (typeof self !== 'undefined' &&
typeof self.location !== 'undefined') {
// Browser web worker — resolve relative to worker script URL
const base = self.location.href.replace(/\/[^/]*$/, '/');
return {
spend: `${base}sapling-spend.params`,
output: `${base}sapling-output.params`,
};
}
/* eslint-enable no-restricted-globals */
// Node.js — resolve relative to __filename
// eslint-disable-next-line no-eval
const req = eval('require');
const path = req('path');
const dir = path.dirname(__filename);
return {
spend: `file://${path.join(dir, 'sapling-spend.params')}`,
output: `file://${path.join(dir, 'sapling-output.params')}`,
};
}
/** Fetch sapling parameters from a URL */
async function fetchParams(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch params from ${url}: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
* Initialize sapling parameters (lazy load from CDN / disk if not bundled).
* Called automatically before any proof generation.
*/
const initSaplingParams = async () => {
if (saplingParamsInitialized)
return;
if (saplingParamsLoading)
return saplingParamsLoading;
saplingParamsLoading = (async () => {
try {
const urls = saplingParamsUrls ?? getDefaultParamsUrls();
console.log('Loading sapling parameters...');
const startTime = Date.now();
const [spendParams, outputParams] = await Promise.all([
fetchParams(urls.spend),
fetchParams(urls.output),
]);
await sapling.initParameters(spendParams, outputParams);
saplingParamsInitialized = true;
console.log(`Sapling parameters loaded in ${Date.now() - startTime}ms`);
}
catch (error) {
console.error('Failed to load sapling parameters:', error);
throw error;
}
finally {
saplingParamsLoading = null;
}
})();
return saplingParamsLoading;
};
/** Check if sapling params are loaded */
const areSaplingParamsLoaded = () => saplingParamsInitialized;
/** Preload sapling params (call early to reduce latency) */
const preloadSaplingParams = () => {
initSaplingParams().catch(console.error);
};
// ---------------------------------------------------------------------------
// Module-level singleton state
// ---------------------------------------------------------------------------
let iMSK = null;
let iMVK = null;
let sTk = null;
let isViewOnly = false;
let currentSaplingDetails = null;
let currentRpcAdapter = null;
let currentRpcUrl = null;
// ---------------------------------------------------------------------------
// Incremental sapling-diff cache (see saplingDiffCache.ts)
// ---------------------------------------------------------------------------
// When enabled, balance/transaction reads fetch only the diff DELTA (finalized prefix from a
// persisted offset + the fresh unconfirmed tail) instead of the full pool diff every time.
// The audited decrypt/spend path is unchanged — only the fetch is cheaper. Disabled => exactly
// the prior behaviour (full get_diff at head). Falls back to a full fetch on any cache error.
let diffCacheEnabled = false;
// v2: incremental DECRYPT (balance) cache. Opt-in (default off) — it caches decrypted notes and
// reimplements the balance sum, so it is gated behind a runtime self-check + full fallback.
let balanceCacheEnabled = false;
let diffStore = null;
let diffStoreResolved = false;
const setDiffCacheEnabled = (enabled) => {
diffCacheEnabled = enabled;
};
const setBalanceCacheEnabled = (enabled) => {
balanceCacheEnabled = enabled;
};
/** Inject a persistent store (Node/Lambda/tests). The browser auto-uses IndexedDB. */
const setDiffCacheStore = (store) => {
diffStore = store;
diffStoreResolved = true;
};
/** Resolve the cache store, auto-creating the default (IndexedDB) one on first use. */
const resolveStore = () => {
if (!diffStoreResolved) {
diffStore = createDefaultDiffStore();
diffStoreResolved = true;
}
return diffStore;
};
/**
* The read provider the balance/tx viewer should use: the incremental caching wrapper when the
* cache is enabled and a store is available, otherwise the plain RPC adapter (prior behaviour).
*/
const resolveReadProvider = () => {
const adapter = currentRpcAdapter;
if (!diffCacheEnabled || !currentRpcUrl)
return adapter;
const store = resolveStore();
if (!store)
return adapter;
return makeCachingReadProvider(adapter, currentRpcUrl, store);
};
/** The viewing key (FVK hex) for the loaded account — used to namespace/evict the balance cache. */
const currentFvkHex = async () => {
if (isViewOnly && iMVK)
return Buffer.from(iMVK.getFullViewingKey()).toString('hex');
if (iMSK) {
const vk = await iMSK.getSaplingViewingKeyProvider();
return Buffer.from(vk.getFullViewingKey()).toString('hex');
}
return null;
};
const saplingContractIdOf = () => currentSaplingDetails.saplingId
? { saplingId: currentSaplingDetails.saplingId }
: { contractAddress: currentSaplingDetails.contractAddress };
const diffTargetOf = () => currentSaplingDetails.saplingId
? { kind: 'id', id: currentSaplingDetails.saplingId }
: { kind: 'contract', id: currentSaplingDetails.contractAddress };
// ---------------------------------------------------------------------------
// loadSaplingSecret idempotency cache
// ---------------------------------------------------------------------------
//
// A pooled worker is reused across many operations with an unchanging key and
// (usually) the same set/rpc. Without this guard every call re-runs
// InMemorySpendingKey.fromMnemonic (PBKDF2 + WASM key derivation) and rebuilds
// the SaplingToolkit + RPC adapter. Reusing the live handles is safe because
// SaplingToolkit and SaplingTransactionViewer always re-read on-chain state at
// 'head' on each call, so balances/roots are never served stale.
//
// The cache key holds the raw `sk` for an exact in-memory compare. This is not
// a new exposure: the same plaintext `sk` is already passed into the worker on
// every loadSaplingSecret call and the derived key material (iMSK) already lives
// here for the worker's lifetime; both die when the worker is terminated. We
// deliberately do NOT hash it — hashing would add a WebCrypto dependency that
// throws in non-secure browser contexts (no crypto.subtle), and the secret is
// invariant per worker so the key never needs to be cryptographic.
let lastSkType = null;
let lastSk = null;
let lastContractKey = null;
let lastRpcUrl = null;
const resetLoadCacheKeys = () => {
lastSkType = null;
lastSk = null;
lastContractKey = null;
lastRpcUrl = null;
};
// ---------------------------------------------------------------------------
// Core functions
// ---------------------------------------------------------------------------
const createExtendedSpendingKey = async (mnemonic) => {
const fullSeed = await bip39.mnemonicToSeed(mnemonic);
const first32 = fullSeed.subarray(0, 32);
const second32 = fullSeed.subarray(32);
const seed = Buffer.from(
// eslint-disable-next-line no-bitwise
first32.map((byte, index) => byte ^ second32[index]));
const spendingKeyArr = new Uint8Array(await sapling.getExtendedSpendingKey(seed, 'm/'));
return b58Encode(spendingKeyArr, PrefixV2.SaplingSpendingKey);
};
const loadSaplingSecret = async ({ sk, saplingDetails, rpcUrl, skType = MNEMONIC_METHOD, }) => {
const contractKey = `${saplingDetails.saplingId ?? saplingDetails.contractAddress}:${saplingDetails.memoSize}`;
// Warm-worker fast path: the same key + contract + rpc are already loaded and
// the required handles are live — reuse them without re-deriving the key or
// rebuilding the toolkit. On-chain reads still happen at 'head' per call.
const handlesLive = skType === VIEWING_KEY_METHOD
? isViewOnly && iMVK !== null
: !isViewOnly && iMSK !== null && sTk !== null;
if (skType === lastSkType &&
sk === lastSk &&
contractKey === lastContractKey &&
rpcUrl === lastRpcUrl &&
currentSaplingDetails !== null &&
currentRpcAdapter !== null &&
handlesLive) {
return;
}
try {
// Reset previous state
iMSK = null;
iMVK = null;
isViewOnly = false;
if (skType === SECRET_KEY_METHOD) {
iMSK = new InMemorySpendingKey(sk);
}
else if (skType === MNEMONIC_METHOD) {
iMSK = await InMemorySpendingKey.fromMnemonic(sk);
}
else if (skType === VIEWING_KEY_METHOD) {
iMVK = new InMemoryViewingKey(sk);
isViewOnly = true;
}
else {
throw new Error('Invalid account loading method provided');
}
// Store sapling details and RPC adapter for later use
currentSaplingDetails = saplingDetails;
currentRpcAdapter = new RpcReadAdapter(new RpcClient(rpcUrl));
currentRpcUrl = rpcUrl;
}
catch (err) {
iMSK = null;
iMVK = null;
sTk = null;
isViewOnly = false;
currentSaplingDetails = null;
currentRpcAdapter = null;
currentRpcUrl = null;
resetLoadCacheKeys();
throw err;
}
try {
if (!isViewOnly) {
sTk = new SaplingToolkit({ saplingSigner: iMSK }, saplingDetails, currentRpcAdapter);
}
else {
// View-only mode — no SaplingToolkit needed for transactions
sTk = null;
}
}
catch (err) {
iMSK = null;
iMVK = null;
sTk = null;
isViewOnly = false;
currentSaplingDetails = null;
currentRpcAdapter = null;
currentRpcUrl = null;
resetLoadCacheKeys();
throw err;
}
// Record cache keys now that the load fully succeeded.
lastSkType = skType;
lastSk = sk;
lastContractKey = contractKey;
lastRpcUrl = rpcUrl;
};
const getViewingKey = async () => {
if (isViewOnly && iMVK) {
const fvk = iMVK.getFullViewingKey();
return Buffer.from(fvk).toString('hex');
}
if (iMSK) {
const viewingKeyProvider = await iMSK.getSaplingViewingKeyProvider();
const fvk = viewingKeyProvider.getFullViewingKey();
return Buffer.from(fvk).toString('hex');
}
throw new Error('No spending key or viewing key loaded');
};
const getPaymentAddress = async () => {
if (isViewOnly && iMVK) {
return iMVK.getAddress();
}
if (iMSK) {
const viewingKeyProvider = await iMSK.getSaplingViewingKeyProvider();
return viewingKeyProvider.getAddress();
}
throw new Error('No spending key or viewing key loaded');
};
const prepareShieldedTransaction = async (shieldTransactions) => {
if (isViewOnly) {
throw new Error('Cannot prepare transactions with a viewing key. A spending key is required.');
}
await initSaplingParams();
return sTk.prepareShieldedTransaction(shieldTransactions);
};
const prepareUnshieldedTransaction = async (unshieldTransaction) => {
if (isViewOnly) {
throw new Error('Cannot prepare transactions with a viewing key. A spending key is required.');
}
await initSaplingParams();
return sTk.prepareUnshieldedTransaction(unshieldTransaction);
};
const prepareSaplingTransaction = async (saplingTransactions = []) => {
if (isViewOnly) {
throw new Error('Cannot prepare transactions with a viewing key. A spending key is required.');
}
await initSaplingParams();
return sTk.prepareSaplingTransaction(saplingTransactions);
};
/**
* Build the read-only transaction viewer used by getSaplingBalance/getSaplingTransactions.
* When the incremental diff cache is active, build the viewer directly over the caching read
* provider (deriving the viewing key from the spending key for full accounts, which leaves
* `sTk` — and therefore proof generation — completely untouched). When the cache is off, the
* exact prior construction path is preserved.
*/
const buildViewer = async () => {
const provider = resolveReadProvider();
const cachingActive = provider !== currentRpcAdapter;
if (!cachingActive) {
if (isViewOnly && iMVK) {
if (!currentSaplingDetails || !currentRpcAdapter) {
throw new Error('Sapling details not initialized');
}
const saplingContractId = currentSaplingDetails.saplingId
? { saplingId: currentSaplingDetails.saplingId }
: { contractAddress: currentSaplingDetails.contractAddress };
return new SaplingTransactionViewer(iMVK, saplingContractId, currentRpcAdapter);
}
if (sTk)
return sTk.getSaplingTransactionViewer();
throw new Error('No sapling toolkit or viewing key available');
}
if (!currentSaplingDetails) {
throw new Error('Sapling details not initialized');
}
let vk;
if (isViewOnly && iMVK) {
vk = iMVK;
}
else if (iMSK) {
vk = await iMSK.getSaplingViewingKeyProvider();
}
else {
throw new Error('No sapling toolkit or viewing key available');
}
const saplingContractId = currentSaplingDetails.saplingId
? { saplingId: currentSaplingDetails.saplingId }
: { contractAddress: currentSaplingDetails.contractAddress };
return new SaplingTransactionViewer(vk, saplingContractId, provider);
};
const getSaplingBalance = async () => {
// v2: incremental balance (decrypt-only-new) when opted in and a store is available. Drives the
// per-account balance cache, which self-checks against the stock getBalance() and falls back on
// any divergence or error — so it can never silently surface a wrong balance.
if (balanceCacheEnabled &&
diffCacheEnabled &&
currentRpcUrl &&
currentSaplingDetails) {
const store = resolveStore();
let vk = null;
if (isViewOnly && iMVK)
vk = iMVK;
else if (iMSK)
vk = await iMSK.getSaplingViewingKeyProvider();
if (store && vk) {
const viewer = new SaplingTransactionViewer(vk, saplingContractIdOf(), resolveReadProvider());
const fvkHex = Buffer.from(vk.getFullViewingKey()).toString('hex');
const balance = await incrementalBalance({
store,
rpcUrl: currentRpcUrl,
target: diffTargetOf(),
viewer: viewer,
fvkHex,
});
return balance.toNumber();
}
}
const txViewer = await buildViewer();
const balance = await txViewer.getBalance();
return balance.toNumber();
};
/** Evict this account's cached balances (call on account forget — removes decrypted data at rest). */
const clearShieldedBalanceCache = async () => {
const store = resolveStore();
if (!store)
return;
const fvkHex = await currentFvkHex();
if (fvkHex)
await clearForViewingKey(store, fvkHex);
};
// Readable conversion of a raw note (mirrors octez.js's internal `readableFormat`, which isn't
// re-exported): value bytes → base-16 number, memo bytes → utf8 (trailing zero-padding stripped),
// payment address bytes → zet1… b58.
const noteValue = (v) => new BigNumber(Buffer.from(v).toString('hex'), 16).toNumber();
const noteMemo = (m) => {
const hex = Buffer.from(m).toString('hex');
const match = hex.match(/^(.*?)(?:00)+$/);
const trimmed = match ? match[1] : hex;
return trimmed === '' ? '' : bytesToString(trimmed);
};
const noteAddress = (a) => b58Encode(a, PrefixV2.SaplingAddress);
const rcmHex = (r) => Buffer.from(r).toString('hex');
/**
* Format raw incoming/outgoing notes and tag the "self-sent" set. A note you created to YOURSELF
* (change, or a shield to your own address) is decryptable as BOTH receiver and sender, so it shows
* up in the incoming AND outgoing lists with the SAME commitment trapdoor (rcm). Matching by rcm
* flags that set EXACTLY — no address/value/memo guessing — so the app can separate real
* receives/sends from internal change no matter which diversified address the change went to.
* Pure + exported for unit testing.
*/
export function notesToHistory(raw) {
const incomingRcm = new Set(raw.incoming.map((t) => rcmHex(t.randomCommitmentTrapdoor)));
const outgoingRcm = new Set(raw.outgoing.map((t) => rcmHex(t.randomCommitmentTrapdoor)));
return {
incoming: raw.incoming.map((t) => ({
value: noteValue(t.value),
memo: noteMemo(t.memo),
paymentAddress: noteAddress(t.paymentAddress),
isSpent: !!t.isSpent,
isChange: outgoingRcm.has(rcmHex(t.randomCommitmentTrapdoor)),
})),
outgoing: raw.outgoing.map((t) => ({
value: noteValue(t.value),
memo: noteMemo(t.memo),
paymentAddress: noteAddress(t.paymentAddress),
isChange: incomingRcm.has(rcmHex(t.randomCommitmentTrapdoor)),
})),
};
}
const getSaplingTransactions = async () => {
const txViewer = await buildViewer();
// RAW viewer (same decryption cost as the readable one — it's what readable wraps) so we get each
// note's rcm for the self-sent / change detection in notesToHistory.
const raw = await txViewer.getIncomingAndOutgoingTransactionsRaw();
return notesToHistory(raw);
};
const reInitializeSapling = () => {
iMSK = null;
iMVK = null;
sTk = null;
isViewOnly = false;
currentSaplingDetails = null;
currentRpcAdapter = null;
currentRpcUrl = null;
resetLoadCacheKeys();
};
/**
* Set custom base URL for sapling parameters.
* Must be called before any proof generation (before initSaplingParams).
*/
const setSaplingParamsUrl = (baseUrl) => {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
saplingParamsUrls = {
spend: `${normalizedBase}sapling-spend.params`,
output: `${normalizedBase}sapling-output.params`,
};
};
/**
* Set explicit URLs for individual sapling parameter files.
* Must be called before any proof generation (before initSaplingParams).
*/
const setSaplingParamsUrls = (urls) => {
saplingParamsUrls = { ...urls };
};
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export const saplingWorkerCore = {
createExtendedSpendingKey,
loadSaplingSecret,
getPaymentAddress,
getViewingKey,
prepareShieldedTransaction,
prepareUnshieldedTransaction,
prepareSaplingTransaction,
getSaplingBalance,
getSaplingTransactions,
reInitializeSapling,
initSaplingParams,
areSaplingParamsLoaded,
preloadSaplingParams,
setSaplingParamsUrl,
setSaplingParamsUrls,
setDiffCacheEnabled,
setBalanceCacheEnabled,
setDiffCacheStore,
clearShieldedBalanceCache,
};
//# sourceMappingURL=saplingCore.js.map