shield-bridge-sdk
Version:
1,936 lines • 103 kB
JavaScript
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
if (kind === "m") throw new TypeError("Private method is not writable");
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var _ShieldBridgeSDK_saplingKeyInfo;
import { Buffer } from 'buffer';
import * as Comlink from 'comlink';
import { OpKind, } from '@tezos-x/octez.js';
import BigNumber from 'bignumber.js';
import { SaplingWorkerPool, DEFAULT_POOL_SIZE } from './workerPool.js';
import { OperationIndex } from './types.js';
import { shieldBridgeContract as shieldBridgeContractAddresses, saplingMapContract, tzktApiMap, } from './constants.js';
import { toBaseUnits, validateAmount } from './utils/amount.js';
// Constants — re-exported for consumers
export { shieldBridgeContract, saplingFactoryContract, saplingMapContract, saplingStateMapContract, tzktApiMap, } from './constants.js';
// Worker pool — re-exported for consumers
export { SaplingWorkerPool, DEFAULT_POOL_SIZE, DEFAULT_IDLE_TIMEOUT_MS, } from './workerPool.js';
// Incremental sapling-diff cache — safe to re-export as values (no heavy deps; uses only
// fetch + IndexedDB), so Node/Lambda consumers can inject a store.
export { MemoryDiffStore, IndexedDbDiffStore, createDefaultDiffStore, makeCachingReadProvider, } from './saplingDiffCache.js';
// Make Buffer available globally for octez.js dependencies
if (typeof window !== 'undefined' && !window.Buffer) {
window.Buffer = Buffer;
}
const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
// Default to loading the unbundled worker
let workerUrl = './worker';
// Default sapling params URLs — resolved by the consumer's bundler via import.meta.url
// Bundlers (Vite, webpack 5) recognize `new URL('./file', import.meta.url)` and emit the
// referenced files as separate assets, returning the final public URL automatically.
let defaultSpendParamsUrl;
let defaultOutputParamsUrl;
if (isBrowser) {
// Load the worker bundle in the browser environment
workerUrl = new URL('./saplingWorker.js', import.meta.url).href;
// Resolve sapling params relative to this module — bundler copies them to output
defaultSpendParamsUrl = new URL('./sapling-spend.params', import.meta.url)
.href;
defaultOutputParamsUrl = new URL('./sapling-output.params', import.meta.url)
.href;
}
/**
* Realign an `estimate.batch()` result with the ops we asked it to estimate, for an UNREVEALED
* source account.
*
* octez.js auto-prepends a reveal operation when the source's public key isn't yet on-chain, and
* `estimate.batch()` returns that reveal's Estimate as element [0] — but, unlike its single-op
* estimators (`contractCall`/`transfer`/…, which call `estimateProperties.shift()`), it deliberately
* does NOT drop it (see RPCEstimateProvider.batch). We pin gasLimit/storageLimit/fee POSITIONALLY per
* op, so a stray leading reveal estimate mis-gasses everything: op0 gets the reveal's ~1000-gas
* limits, every op is shifted by one, and the last op's estimate is silently dropped — the node then
* rejects the broadcast with gas_exhausted / fees_too_low. (This is why a fresh wallet "fails on
* simulate" until revealed out-of-band.)
*
* Drop the leading reveal estimate so the array maps 1:1 onto our ops; the wallet re-adds and funds
* the actual reveal at injection time. No-op for an already-revealed source (length === opCount).
*/
export function realignEstimatesForReveal(estimates, expectedOpCount) {
return estimates.length === expectedOpCount + 1
? estimates.slice(1)
: estimates;
}
/**
* ShieldBridgeSDK provides an abstraction to interact with the Shield Bridge smart contract
* to shield, unshield, and transfer sapling tokens.
*
* The SDK supports two modes of operation:
*
* 1. **Full Access Mode** (with spending key or mnemonic):
* - Can perform all operations: shield, unshield, transfer
* - Can query balances and transactions
* - Can export viewing keys for read-only access
*
* 2. **View-Only Mode** (with viewing key):
* - Can only query balances and transactions
* - Cannot perform transaction operations
* - Useful for auditing, monitoring, and compliance
*
* @class
* @param {ShieldBridgeSDKConfig} config The configuration object for the Shield Bridge SDK
* @param {TezosToolkit} config.client The TezosToolkit instance
* @param {'mainnet' | 'shadownet'} [config.tzktApi='mainnet'] The tzkt API to use
* @param {number} [config.minConfirmations=1] The minimum number of confirmations for the transaction
* @param {string} [config.saplingStateMapContract='KT1RYEs6rfXgHqeb2XzfHKRii5NsNyKbS6WM'] The sapling state map contract address
* @param {boolean} [config.useBaseUnits=false] Whether to use base unit for the token amounts (mutez or token units with decimals)
* @param {boolean} [config.parallelThreads=true] Whether to spawn parallel threads for the sapling worker
* @param {boolean} [config.saplingDiffCache=true] Cache the sapling-diff delta (finalized prefix + fresh tail) instead of refetching the full pool diff each read — far less RPC. Browser auto-uses IndexedDB; Node/Lambda supplies `saplingDiffStore`.
* @param {boolean} [config.saplingBalanceCache=false] Opt-in "v2" decrypt cache: also caches decrypted notes and decrypts only new commitments (O(new)). Self-checked against the stock balance; decrypted notes are encrypted at rest under the viewing key.
* @param {SaplingDiffStore} [config.saplingDiffStore] Persistent store backing the diff cache in Node/Lambda (direct mode only); the browser auto-uses IndexedDB.
* @param {string} [config.saplingSecret] The sapling secret key (for full access mode)
* @param {string} [config.saplingMnemonic] The sapling mnemonic (for full access mode)
* @param {string} [config.saplingViewingKey] The sapling viewing key (for view-only mode)
* @returns {ShieldBridgeSDK} The Shield Bridge SDK instance
*
* @example
* // Full access mode with secret key
* const tezos = new TezosToolkit('https://mainnet.api.tez.ie');
* const signerProvider = await InMemorySigner.fromSecretKey('edsk...');
* tezos.setSignerProvider(signerProvider);
* const shieldBridge = new ShieldBridgeSDK({
* client: tezos,
* saplingSecret: 'sask...'
* });
* await shieldBridge.shield([
* {
* amount: 1,
* contract: 'KT1...',
* tokenId: 0,
* memo: 'abcdefgh'
* }
* ]);
*
* @example
* // Export viewing key for read-only access
* const viewingKey = await shieldBridge.getViewingKey();
*
* @example
* // View-only mode with viewing key
* const viewOnlySdk = new ShieldBridgeSDK({
* client: tezos,
* saplingViewingKey: 'abc123...'
* });
* const balance = await viewOnlySdk.getShieldedBalance({});
* console.log('View-only mode:', viewOnlySdk.isViewOnlyMode); // true
*/
export class ShieldBridgeSDK {
/**
* The contract address used for operations.
* - V2: Factory contract address
* - V1: Map contract address
* @deprecated Use shieldBridgeContractAddress instead.
*/
get saplingStateMapContract() {
return this.shieldBridgeContractAddress;
}
constructor(config) {
/** Worker pool for parallel operations (null when parallelThreads is false) */
this.workerPool = null;
/** Counter for in-flight operations to prevent architecture switches during active work */
this.operationsInFlight = 0;
/**
* Await op.confirmation() with a visibility-change recovery for mobile browsers.
*
* When the user switches to a wallet app to sign, the browser tab is backgrounded
* and timers are throttled/frozen. op.confirmation() uses RxJS polling (setInterval)
* that stalls on backgrounded tabs. The polling resumes on return but needs to walk
* through every missed block sequentially, which can take a very long time.
*
* This helper races op.confirmation() against visibility/focus listeners that
* query TzKT for the operation status when the tab regains focus, bypassing the
* stalled block-by-block walk entirely.
*
* Uses both `visibilitychange` and `focus` because iOS Safari sometimes fails
* to fire `visibilitychange` when switching between native apps.
*/
this.awaitConfirmation = (op) => {
if (typeof document === 'undefined') {
return op.confirmation(this.minConfirmations);
}
return new Promise((resolve, reject) => {
let settled = false;
const checkTzKT = () => {
if (settled)
return;
fetch(`${this.tzktBaseUrl}/v1/operations/${op.opHash}`)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data && Array.isArray(data) && data.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
settle(data[0]);
}
})
.catch(() => {
// TzKT unavailable — fall through to normal polling
});
};
const onResume = () => {
if (settled)
return;
// For visibilitychange, only act when becoming visible
if (document.visibilityState !== undefined &&
document.visibilityState !== 'visible') {
return;
}
checkTzKT();
};
const cleanup = () => {
document.removeEventListener('visibilitychange', onResume);
window.removeEventListener('focus', onResume);
};
const settle = (result) => {
if (!settled) {
settled = true;
cleanup();
resolve(result);
}
};
const fail = (err) => {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
};
// visibilitychange: standard API, works on most browsers
// focus: fallback for iOS Safari app-switching where visibilitychange can miss
document.addEventListener('visibilitychange', onResume);
window.addEventListener('focus', onResume);
// Immediate TzKT check: on mobile, the user may return to the app
// (triggering visibilitychange) BEFORE batch.send() resolves and
// awaitConfirmation registers its listeners. By the time we get here
// the page is already visible and no new event will fire.
if (document.visibilityState === 'visible') {
checkTzKT();
}
op.confirmation(this.minConfirmations)
.then(settle)
.catch(fail);
});
};
// Cache for set contract addresses (V2) or sapling IDs (V1)
this.setAddressCache = new Map();
// Cache for sapling IDs (V1 only)
this.saplingIdCache = new Map();
this.tokenDecimalsCache = new Map();
this.tokenMetadataCache = new Map();
// Cache for contract instances
this.walletContractCache = new Map();
this.estimatorContractCache = new Map();
/**
* Memoized factory storage snapshot (V2). The factory abstraction and its
* top-level storage are immutable for a given contract address, and big-map
* `.get()` lookups always issue a fresh head RPC, so a single snapshot serves
* every per-token set-address lookup without re-fetching the storage.
* Reset on architecture switch, destroy, and on fetch error.
*/
this.factoryStoragePromise = null;
// ── Secret storage (true JS private — inaccessible at runtime) ──
/** The sapling key type and value, extracted once and never re-exposed */
_ShieldBridgeSDK_saplingKeyInfo.set(this, void 0);
/**
* @description Helper to create a worker instance compatible with both Browser and Node.js
*/
this.createWorker = async () => {
let worker;
let endpoint;
if (typeof window === 'undefined') {
// Node.js environment
// eslint-disable-next-line no-eval
const req = eval('require');
const { Worker } = req('worker_threads');
const nodeEndpoint = req('comlink/dist/umd/node-adapter');
const path = req('path');
const { fileURLToPath } = req('url');
// Resolve path to the Node worker bundle relative to this file.
// In dist/, index.js and saplingWorker.cjs are siblings. We load the
// `.cjs` (CommonJS) Node bundle here — NOT the browser `saplingWorker.js`
// — because package.json sets "type":"module", so Node would evaluate a
// `.js` worker as ESM where the worker's `eval('require')` throws.
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workerPath = path.join(currentDir, 'saplingWorker.cjs');
worker = new Worker(workerPath);
endpoint = nodeEndpoint(worker);
}
else {
// Browser environment
worker = new Worker(workerUrl);
endpoint = worker;
}
const proxy = Comlink.wrap(endpoint);
// Wire sapling params URLs to the worker
// Priority: explicit saplingParamsUrl config > bundler-resolved URLs > worker's own resolution
if (this.saplingParamsUrl) {
await proxy.setSaplingParamsUrl(this.saplingParamsUrl);
}
else if (defaultSpendParamsUrl && defaultOutputParamsUrl) {
await proxy.setSaplingParamsUrls({
spend: defaultSpendParamsUrl,
output: defaultOutputParamsUrl,
});
}
// Incremental diff cache: enable per worker. The store itself is NOT sent across the
// Comlink boundary (it can't carry methods) — inside a Web Worker the cache auto-uses
// IndexedDB (shared across same-origin workers). Node worker_threads have no IndexedDB,
// so caching there is a no-op unless direct-execution mode + an injected store is used.
await proxy.setDiffCacheEnabled(this.saplingDiffCache);
await proxy.setBalanceCacheEnabled(this.saplingBalanceCache);
return proxy;
};
this.initializeSaplingWorker = async () => {
try {
// Direct execution mode: when parallelThreads is false in Node.js,
// use saplingCore directly without spawning any worker threads.
// This is essential for environments like AWS Lambda where worker_threads
// add unnecessary overhead and complexity.
if (!isBrowser && !this.parallelThreads) {
const { saplingWorkerCore } = await import('./saplingCore.js');
// The core functions have the same async interface as Comlink.Remote<SaplingWorker>
// since all functions return Promises, making the cast safe at runtime.
this.saplingWorker =
saplingWorkerCore;
// Wire sapling params URLs for direct mode
if (this.saplingParamsUrl) {
saplingWorkerCore.setSaplingParamsUrl(this.saplingParamsUrl);
}
// Incremental diff cache for direct mode. Here we CAN inject a store object (same
// execution context — no Comlink boundary), so Node/Lambda can opt in with
// `saplingDiffStore`; otherwise it auto-uses IndexedDB if present, else stays a no-op.
saplingWorkerCore.setDiffCacheEnabled(this.saplingDiffCache);
saplingWorkerCore.setBalanceCacheEnabled(this.saplingBalanceCache);
if (this.saplingDiffStore) {
saplingWorkerCore.setDiffCacheStore(this.saplingDiffStore);
}
return true;
}
// In parallel mode the pool owns all workers (created lazily on checkout),
// and every executor reassigns its worker from the pool before use — so a
// standalone primary worker would be spawned but never run. Only the
// single-worker path (browser + parallelThreads:false) needs a primary.
if (this.parallelThreads) {
this.workerPool = new SaplingWorkerPool(this.maxPoolSize, () => this.createWorker());
}
else {
this.saplingWorker = await this.createWorker();
}
return true;
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('[ShieldBridgeSDK] Failed to initialize Sapling worker:', message);
throw new Error(`Failed to initialize Sapling worker: ${message}`);
}
};
/**
* @description Get the sapling key type and value
* @returns The key type ('secretKey' | 'mnemonic' | 'viewingKey') and the key value
*/
this.getSaplingKeyInfo = () => __classPrivateFieldGet(this, _ShieldBridgeSDK_saplingKeyInfo, "f");
/**
* @description Get cached contract or fetch and cache it
* @param contractAddress The contract address
*/
this.getContract = async (contractAddress) => {
const cached = this.walletContractCache.get(contractAddress);
if (cached)
return cached;
const contract = await this.tezosClient.wallet.at(contractAddress);
this.walletContractCache.set(contractAddress, contract);
return contract;
};
/**
* @description Get cached estimator contract or fetch and cache it
* @param contractAddress The contract address
*/
this.getEstimatorContract = async (contractAddress) => {
const cached = this.estimatorContractCache.get(contractAddress);
if (cached)
return cached;
const contract = await this.tezosClient.contract.at(contractAddress);
this.estimatorContractCache.set(contractAddress, contract);
return contract;
};
/**
* @description Get the memoized factory storage snapshot (V2), reusing the
* cached factory contract abstraction. Big-map `.get()` lookups off the
* snapshot stay live (each issues a fresh head RPC), so this only collapses
* the repeated `contract.at()` + `storage()` round-trips, not per-token
* freshness. Used by getSetAddress, which holds its own per-key result cache.
*/
this.getFactoryStorage = () => {
if (this.factoryStoragePromise) {
return this.factoryStoragePromise;
}
const storagePromise = (async () => {
const factoryContract = await this.getEstimatorContract(this.shieldBridgeContractAddress);
return factoryContract.storage();
})();
// Null out on rejection so a transient RPC failure doesn't poison every
// subsequent token lookup with a permanently-rejected promise.
storagePromise.catch(() => {
if (this.factoryStoragePromise === storagePromise) {
this.factoryStoragePromise = null;
}
});
this.factoryStoragePromise = storagePromise;
return storagePromise;
};
/**
* @description Helper method to initialize a sapling worker with the sapling secret and state
* @param contract The token contract address (optional)
* @param tokenId The token id (optional)
* @param providedSetAddress The set contract address if already known (V2 only, optional)
* @param providedSaplingId The sapling ID if already known (V1 only, optional)
* @returns The initialized sapling worker, set address/map contract, and token decimals
*/
this.initializeSaplingWorkerWithState = async (contract, tokenId, providedSetAddress, providedSaplingId, providedDecimals) => {
let poolEntry = null;
try {
await this.ready;
let { saplingWorker } = this;
if (this.workerPool) {
poolEntry = await this.workerPool.checkout();
saplingWorker = poolEntry.worker;
}
// Determine the key type and value based on what's provided in the config
const { sk, skType } = this.getSaplingKeyInfo();
// Handle V1 vs V2 architecture differently
let setAddress;
if (this.contractArchitecture === '1') {
// V1: Use saplingId and map contract
const saplingId = providedSaplingId ?? (await this.getSaplingId(contract, tokenId));
if (saplingId === undefined) {
throw new Error(`Sapling state not initialized for ${ShieldBridgeSDK.formatTokenInfo(contract, tokenId)}`);
}
await saplingWorker.loadSaplingSecret({
sk,
skType,
saplingDetails: {
contractAddress: this.shieldBridgeContractAddress,
memoSize: 8,
saplingId: `${saplingId}`,
},
rpcUrl: this.tezosClient.rpc.getRpcUrl(),
});
// For V1, setAddress is the map contract itself
setAddress = this.shieldBridgeContractAddress;
}
else {
// V2: Use setAddress (individual set contract)
// Fetch set address and token decimals in parallel (both independent). When the caller
// already knows the decimals (e.g. from its own asset metadata), use them and skip the
// per-token TzKT `/v1/tokens` lookup entirely.
let decimalsPromise;
if (providedDecimals !== undefined) {
decimalsPromise = Promise.resolve(providedDecimals);
}
else if (contract) {
decimalsPromise = this.getTokenDecimals(contract, tokenId);
}
else {
decimalsPromise = Promise.resolve(6);
}
const fetchedSetAddress = providedSetAddress ?? (await this.getSetAddress(contract, tokenId));
if (!fetchedSetAddress) {
throw new Error(`Sapling set not initialized for ${ShieldBridgeSDK.formatTokenInfo(contract, tokenId)}`);
}
await saplingWorker.loadSaplingSecret({
sk,
skType,
saplingDetails: {
contractAddress: fetchedSetAddress,
memoSize: 8,
},
rpcUrl: this.tezosClient.rpc.getRpcUrl(),
});
setAddress = fetchedSetAddress;
// Await decimals (likely already resolved since set address fetch was slower)
const tokenDecimals = await decimalsPromise;
return { saplingWorker, setAddress, tokenDecimals, poolEntry };
}
// V1 path: token decimals — use the caller's value if given, else fetch (after loadSaplingSecret)
let tokenDecimals = 6;
if (providedDecimals !== undefined) {
tokenDecimals = providedDecimals;
}
else if (contract) {
tokenDecimals = await this.getTokenDecimals(contract, tokenId);
}
return { saplingWorker, setAddress, tokenDecimals, poolEntry };
}
catch (error) {
// Release pool entry on error to prevent pool exhaustion
if (poolEntry) {
this.workerPool?.release(poolEntry);
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to initialize sapling worker for ${ShieldBridgeSDK.formatTokenInfo(contract, tokenId)}: ${errorMessage}`);
}
};
/**
* @description Execute an operation with a properly managed sapling worker.
* Ensures the parallel worker is always released after the operation completes
* or throws, preventing memory leaks from orphaned Web Workers.
*
* @param fn Callback receiving the initialized worker, token decimals, and set address
* @param contract Optional token contract address
* @param tokenId Optional token ID
* @param providedSetAddress Optional pre-resolved set address (V2)
* @param providedSaplingId Optional pre-resolved sapling ID (V1)
* @returns The result of the callback
*/
this.withWorker = async (fn, contract, tokenId, providedSetAddress, providedSaplingId, providedDecimals) => {
const { saplingWorker, setAddress, tokenDecimals, poolEntry } = await this.initializeSaplingWorkerWithState(contract, tokenId, providedSetAddress, providedSaplingId, providedDecimals);
try {
return await fn(saplingWorker, tokenDecimals, setAddress);
}
finally {
if (poolEntry) {
this.workerPool?.release(poolEntry);
}
}
};
/**
* @description Get the sapling set contract address for the token contract and token id if provided
* @param {string} [contract] The token contract address
* @param {number} [tokenId] The token id
* @returns The sapling set contract address for the token contract and token id if provided
* @note This method is for V2 (Factory) architecture. For V1, use getSaplingId instead.
*/
this.getSetAddress = async (contract, tokenId) => {
// Create cache key
const cacheKey = contract
? `${contract}${tokenId !== undefined ? `:${tokenId}` : ''}`
: 'tez';
// Check cache first and return the promise if it exists
if (this.setAddressCache.has(cacheKey)) {
return this.setAddressCache.get(cacheKey);
}
// Create and cache the promise to prevent duplicate concurrent requests
const setAddressPromise = (async () => {
try {
let setAddress;
if (contract) {
// Token sets use the memoized factory storage snapshot — the big-map
// `.get()` below still hits RPC fresh at head, so the snapshot only
// collapses the repeated storage fetch and never freezes a result.
const factoryStorage = await this.getFactoryStorage();
if (tokenId !== undefined) {
// FA2 token - lookup in token_fa_2 big map
setAddress = await factoryStorage.token_fa_2.get({
contract,
token_id: tokenId,
});
}
else {
// FA1.2 token - lookup in token_fa_1_2 big map
setAddress = await factoryStorage.token_fa_1_2.get(contract);
}
}
else {
// TEZ is a plain storage field, not a live big-map getter, so the
// memoized snapshot would freeze it for the SDK lifetime. Fetch fresh
// (via the cached factory abstraction) so a tez set deployed
// mid-session is picked up — the undefined-eviction below keeps
// re-resolving it until then.
const factoryContract = await this.getEstimatorContract(this.shieldBridgeContractAddress);
const factoryStorage = await factoryContract.storage();
setAddress = factoryStorage.tez || undefined;
}
return setAddress;
}
catch (error) {
// Remove from cache on error so it can be retried
this.setAddressCache.delete(cacheKey);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to get set address for ${cacheKey}: ${message}`);
}
})();
// Cache the promise immediately before any await
this.setAddressCache.set(cacheKey, setAddressPromise);
// Don't pin a not-yet-initialized set (undefined) forever: evict it once
// resolved so a later initTokenSaplingSet is picked up on the next lookup.
// Deployed (immutable) set addresses stay cached. The identity guard avoids
// clobbering a concurrent getAllShieldedAssets population of the same key.
setAddressPromise
.then((value) => {
if (value === undefined &&
this.setAddressCache.get(cacheKey) === setAddressPromise) {
this.setAddressCache.delete(cacheKey);
}
})
.catch(() => {
// Rejections already evict via the catch above.
});
return setAddressPromise;
};
/**
* @deprecated V1 architecture is deprecated. Use V2 (Factory) with getSetAddress instead.
* @description Get the sapling ID for the token contract and token id (V1 Map architecture)
* @param {string} [contract] The token contract address
* @param {number} [tokenId] The token id
* @returns The sapling ID for the token in the map contract storage
*/
this.getSaplingId = async (contract, tokenId) => {
// Create cache key
const cacheKey = contract
? `${contract}${tokenId !== undefined ? `:${tokenId}` : ''}`
: 'tez';
// Check cache first and return the promise if it exists
if (this.saplingIdCache.has(cacheKey)) {
return this.saplingIdCache.get(cacheKey);
}
// Create and cache the promise to prevent duplicate concurrent requests
const saplingIdPromise = (async () => {
try {
// Fetch map contract storage using TzKT API
const contractStorage = await fetch(`${this.tzktBaseUrl}/v1/contracts/${this.shieldBridgeContractAddress}/storage`).then((res) => {
if (!res.ok) {
throw new Error(`Failed to fetch contract storage: ${res.status} ${res.statusText}`);
}
return res.json();
});
let saplingId;
if (contract) {
if (tokenId !== undefined) {
// FA2 token - find in token_fa_2 array
saplingId = contractStorage.token_fa_2.find((token) => token.key.address === contract &&
token.key.nat === `${tokenId}`)?.value;
}
else {
// FA1.2 token - lookup in token_fa_1_2 map
saplingId = contractStorage.token_fa_1_2[contract];
}
}
else {
// TEZ - direct storage field
saplingId = contractStorage.tez;
}
return saplingId;
}
catch (error) {
// Remove from cache on error so it can be retried
this.saplingIdCache.delete(cacheKey);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to get sapling ID for ${cacheKey}: ${message}`);
}
})();
// Cache the promise immediately before any await
this.saplingIdCache.set(cacheKey, saplingIdPromise);
// Don't pin a not-yet-initialized sapling state (undefined) forever: evict
// it once resolved so a later registration is picked up. A valid sapling ID
// can be 0, so evict strictly on undefined. The identity guard avoids races.
saplingIdPromise
.then((value) => {
if (value === undefined &&
this.saplingIdCache.get(cacheKey) === saplingIdPromise) {
this.saplingIdCache.delete(cacheKey);
}
})
.catch(() => {
// Rejections already evict via the catch above.
});
return saplingIdPromise;
};
/**
* @description Get the metadata for the token contract and token id if provided
* @param {string} contract The token contract address
* @param {number} [tokenId] The token id
* @returns The metadata for the token contract and token id if provided
*
* @note Uses TzKT API which automatically decodes token metadata from bytes.
* Taquito RPC returns raw big map structures that require manual decoding.
*/
this.getTokenMetadata = async (contract, tokenId) => {
// Create cache key
const cacheKey = `${contract}${tokenId !== undefined ? `:${tokenId}` : ''}`;
// Check cache first and return the promise if it exists
if (this.tokenMetadataCache.has(cacheKey)) {
return this.tokenMetadataCache.get(cacheKey);
}
// Create and cache the promise to prevent duplicate concurrent requests
const metadataPromise = (async () => {
try {
// Use TzKT API to get decoded token metadata
// TzKT automatically decodes metadata bytes and handles TZIP-12/16 standards
const tokenIdParam = tokenId !== undefined ? `&token.tokenId=${tokenId}` : '';
const response = await fetch(`${this.tzktBaseUrl}/v1/tokens?contract=${contract}${tokenIdParam}&limit=1`);
const tokens = await response.json();
if (tokens && tokens.length > 0) {
return tokens[0].metadata;
}
return {};
}
catch (error) {
// Remove from cache on error so it can be retried
this.tokenMetadataCache.delete(cacheKey);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to get token metadata for ${cacheKey}: ${message}`);
}
})();
// Cache the promise immediately before any await
this.tokenMetadataCache.set(cacheKey, metadataPromise);
return metadataPromise;
};
/**
* @description Get the number of decimals for the token contract and token id if provided
* @param {string} contract The token contract address
* @param {number} [tokenId] The token id
* @returns The number of decimals for the token contract and token id if provided
*/
this.getTokenDecimals = async (contract, tokenId) => {
// Create cache key
const cacheKey = `${contract}${tokenId !== undefined ? `:${tokenId}` : ''}`;
// Check cache first and return the promise if it exists
if (this.tokenDecimalsCache.has(cacheKey)) {
return this.tokenDecimalsCache.get(cacheKey);
}
// Create and cache the promise to prevent duplicate concurrent requests
const decimalsPromise = (async () => {
try {
const { decimals } = (await this.getTokenMetadata(contract, tokenId));
if (!decimals) {
throw new Error(`Token metadata missing 'decimals' field for ${cacheKey}`);
}
const parsed = parseInt(decimals, 10);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid decimals value "${decimals}" for ${cacheKey}`);
}
return parsed;
}
catch (error) {
// Remove from cache on error so it can be retried
this.tokenDecimalsCache.delete(cacheKey);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to get token decimals for ${cacheKey}: ${message}`);
}
})();
// Cache the promise immediately before any await
this.tokenDecimalsCache.set(cacheKey, decimalsPromise);
return decimalsPromise;
};
/**
* @description Get the total shielded pool balances across all set contracts
* @returns The aggregated balances from all individual set contracts
*
* @note In the factory architecture, the factory contract itself holds no balances.
* Each token type has its own set contract that holds the actual assets.
* This method aggregates balances from all individual set contracts.
*/
this.getTotalShieldedSetBalances = async () => {
// Get all set contracts from the factory
const setAssets = await this.getAllShieldedAssets();
// Query balances from each set contract in parallel, isolating errors
const balanceResults = await Promise.allSettled(setAssets.map(async (asset) => {
const balances = await fetch(`${this.tzktBaseUrl}/v1/tokens/balances?account=${asset.setAddress}&sort.desc=balanceValue&limit=100&offset=0`).then((res) => res.json());
return balances.map((token) => {
let unitAmount = token.balance;
if (!this.useBaseUnits) {
const decimals = token.token?.metadata?.decimals;
if (!decimals) {
console.warn(`[ShieldBridgeSDK] Missing decimals for token at ${asset.setAddress}, using raw balance`);
}
unitAmount = new BigNumber(unitAmount)
.dividedBy(new BigNumber(10).exponentiatedBy(decimals ?? 0))
.toNumber();
}
return {
...token,
balance: unitAmount,
setAddress: asset.setAddress,
};
});
}));
return balanceResults
.filter((r) => r.status === 'fulfilled')
.flatMap((r) => r.value);
};
/**
* @description Estimate the gas and storage limits for the transaction list of shielding transactions
* @param {OrderedTransactionList} transactionList The constructed transaction list
* @returns The estimated gas and storage limits for the transaction list
*/
this.estimateShieldTransactionLimits = async (transactionList) => {
const contractEstimator = await this.getEstimatorContract(this.shieldBridgeContractAddress);
const batch = [];
for (let index = 0; index < transactionList.length; index += 1) {
/**
* These transactions are not yet formatted for the contract call. The default
* index transactions can be submitted in a single call with a list of transactions.
* This is being done to optimize the number of operations in the transaction.
*/
if (index === OperationIndex.DEFAULT_INDEX) {
const nonTezTransactions = [];
// eslint-disable-next-line no-restricted-syntax
for (const transaction of transactionList[index]) {
// amount is only present for tez deposits
if (transaction.contract) {
nonTezTransactions.push(transaction);
// eslint-disable-next-line no-continue
continue;
}
const { amount } = transaction;
batch.push([
contractEstimator.methodsObject.default([transaction]),
{
amount: amount?.toString(),
mutez: true,
},
]);
}
// If token deposits are present, batch them separately from tez deposits
if (nonTezTransactions.length) {
batch.push([
contractEstimator.methodsObject.default(nonTezTransactions),
{},
]);
}
}
else {
// These transactions are already formatted to be included in the batch call
transactionList[index].forEach((transaction) => {
batch.push([transaction, {}]);
});
}
}
const estimateBatch = batch.map(([operation, params = {}]) => ({
kind: OpKind.TRANSACTION,
// @ts-expect-error string is an acceptable type for amount
...operation.toTransferParams(params),
}));
const estimates = await this.tezosClient.estimate.batch(estimateBatch);
return realignEstimatesForReveal(estimates, estimateBatch.length);
};
/**
* @description Submit sapling deposits/shielding transactions
* @param {SaplingDeposits} saplingDeposits Sapling deposits/shielding transactions to be submitted
* @param {number} saplingDeposits.amount The amount to be shielded
* @param {string[]} saplingDeposits.saplingTransactions The sapling transactions to be submitted
* @param {string} [saplingDeposits.contract] The token contract address
* @param {number} [saplingDeposits.tokenId] The token id
* @param {string} [saplingDeposits.owner] The shielded address to apply the shielded tokens
* @returns The confirmation of the submitted sapling deposits/shielding transactions
*/
this.submitSaplingShieldTransaction = async (saplingDeposits, callbacks) => {
// V2: Call Set contracts directly (bypasses Factory for efficiency)
if (this.contractArchitecture === '2') {
return this.submitSaplingShieldTransactionV2(saplingDeposits, callbacks);
}
// V1: Route through map contract (legacy)
const dappContract = await this.getContract(this.shieldBridgeContractAddress);
const transactionList = [[], [], [], []];
// eslint-disable-next-line no-restricted-syntax
for (const saplingDeposit of saplingDeposits) {
const { owner, amount, saplingTransactions, contract, tokenId } = saplingDeposit;
if (contract) {
// eslint-disable-next-line no-await-in-loop
const tokenContract = await this.getContract(contract);
// V1: The map contract itself is the operator/spender for token approvals
const operator = this.shieldBridgeContractAddress;
if (tokenId !== undefined) {
// FA2 update_operators add_operator
transactionList[OperationIndex.UPDATE_OPERATORS_ADD_INDEX].push(tokenContract.methodsObject.update_operators([
{
add_operator: {
owner,
operator,
token_id: tokenId,
},
},
]));
// Sapling State Contract default
transactionList[OperationIndex.DEFAULT_INDEX].push({
txns: saplingTransactions,
contract,
token_id: tokenId,
});
// FA2 update_operators remove_operator
transactionList[OperationIndex.UPDATE_OPERATORS_REMOVE_INDEX].push(tokenContract.methodsObject.update_operators([
{
remove_operator: {
owner,
operator,
token_id: tokenId,
},
},
]));
}
else {
// FA1.2 approve
transactionList[OperationIndex.APPROVE_INDEX].push(tokenContract.methodsObject.approve({
value: amount,
spender: operator,
}));
// Sapling State Contract default
transactionList[OperationIndex.DEFAULT_INDEX].push({
txns: saplingTransactions,
contract,
});
}
}
else {
// Tez transaction
transactionList[OperationIndex.DEFAULT_INDEX].push({
txns: saplingTransactions,
amount,
});
}
}
const estimates = await this.estimateShieldTransactionLimits(transactionList);
const batch = this.tezosClient.wallet.batch();
for (let index = 0; index < transactionList.length; index += 1) {
/**
* These transactions are not yet formatted for the contract call. The default
* index transactions can be submitted in a single call with a list of transactions.
* This is being done to optimize the number of operations in the transaction.
*/
if (index === OperationIndex.DEFAULT_INDEX) {
const nonTezTransactions = [];
// eslint-disable-next-line no-restricted-syntax
for (const transaction of transactionList[index]) {
const { amount } = transaction;
// amount is only present for tez deposits
if (transaction.contract) {
nonTezTransactions.push(transaction);
// eslint-disable-next-line no-continue
continue;
}
const estimate = estimates.shift();
batch.withContractCall(dappContract.methodsObject.default([transaction]), {
// @ts-expect-error string is an acceptable type for amount
amount,
mutez: true,
gasLimit: estimate.gasLimit,
storageLimit: estimate.storageLimit,
fee: estimate.suggestedFeeMutez,
});
}
// If token deposits are present, batch them separately from tez deposits
if (nonTezTransactions.length) {
const estimate = estimates.shift();
batch.withContractCall(dappContract.methodsObject.default(nonTezTransactions), {
gasLimit: estimate.gasLimit,
storageLimit: estimate.storageLimit,
fee: estimate.suggestedFeeMutez,
});
}
}
else {
// These transactions are already formatted to be included in the batch call
transactionList[index].forEach((transaction) => {
estimates.shift();
batch.withContractCall(transaction);
});
}
}
callbacks?.onSigning?.();
return batch.send().then(async (op) => {
callbacks?.onSubmitting?.({ opHash: op.opHash });
const confirmation = await this.awaitConfirmation(op);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callbacks?.onConfirmed?.({
opHash: op.opHash,
block: confirmation,
});
return { ...confirmation, opHash: op.opHash };
});
};
/**
* @description V2: Submit sapling shield transactions by calling Set contracts directly.
* Bypasses the Factory contract for maximum gas efficiency:
* - FA1.2: approve(Set) → Set.default(txns)
* - FA2: add_operator(Set) → Set.default(txns) → remove_operator(Set)
* - Tez: TezSet.default(txns) with XTZ amount
*/
this.submitSaplingShieldTransactionV2 = async (saplingDeposits, callbacks) => {
const ops = [];
// eslint-disable-next-line no-restricted-syntax
for (const saplingDeposit of saplingDeposits) {
const { owner, amount, saplingTransactions, contract, tokenId } = saplingDeposit;
if (contract) {
// eslint-disable-next-line no-await-in-loop
const tokenContract = await this.getContract(contract);
// eslint-disable-next-line no-await-in-loop
const setAddress = await this.getSetAddress(contract, tokenId);
if (!setAddress) {
throw new Error(`Sapling set address not found for contract ${contract} and tokenId ${tokenId}`);
}
// eslint-disable-next-line no-await-in-loop
const setContract = await this.getContract(setAddress);
if (tokenId !== undefined) {
// FA2: add_operator → Set.default → remove_operator
ops.push({
method: tokenContract.methodsObject.update_operators([
{
add_operator: {
owner,
operator: setAddress,
token_id: tokenId,
},
},
]),
});
ops.push({
method: setContract.methodsObject.default(saplingTransactions),
});
ops.push({
method: tokenContract.methodsObject.update_operators([
{
remove_operator: {
owner,
operator: setAddress,
token_id: tokenId,
},
},
]),
});
}
else {
// FA1.2: approve → Set.default
ops.push({
method: tokenContract.methodsObject.approve({
value: amount.toString(),
spender: setAddress,
}),
});
ops.push({
method: setContract.methodsObject.default(saplingTransactions),
});
}
}
else {
// Tez: call Tez Set directly with amount
// eslint-disable-next-line no-await-in-loop
const setAddress = await this.getSetAddress();
if (!setAddress) {
throw new Error('Tez sapling set address not found');
}
// eslint-disable-next-line no-await-in-loop
const setContract = await this.getContract(setAddress);
ops.push({
method: setContract.methodsObject.default(saplingTransactions),
params: { amount: amount.toString(), mutez: true },
});
}
}
// Estimate all operations
const estimateBatch = ops.map(({ method, params = {} }) => ({
kind: OpKind.TRANSACTION,
// @ts-expect-error string is an acceptable type for amount
...method.toTransferParams(params),
}));
const estimates = realignEstimatesForReveal(await this.tezosClient.estimate.batch(estimateBatch), estimateBatch.length);
// Build and send batch
const batch = this.tezosClient.wallet.batch();
ops.forEach(({ method, params = {} }, i) => {
const estimate = estimates[i];
// @ts-expect-error string is an acceptable type for amount
batch.withContractCall(method, {
...params,
gasLimit: estimate.gasLimit,
storageLimit: estimate.storageLimit,
fee: estimate.suggestedFeeMutez,
});
});
callbacks?.onSigning?.();
return batch.send().then(async (op) => {
callbacks?.onSubmitting?.({ opHash: op.opHash });
const confirmation = await this.awaitConfirmation(op);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callbacks?.onConfirmed?.({
opHash: op.opHash,
block: confirmation,
});
return { ...confirmation, opHash: op.opHash };
});
};
/**
* @description Submit sapling transactions (shared implementation for unshield and transfer)
* @param {SaplingTransactions} saplingTransactions Sapling transactions to be submitted
* @param {string[]} saplingTransactions.saplingTransactions The sapling transactions to be submitted
* @param {string} [saplingTransactions.contract] The token contract address
* @param {number} [saplingTransactions.tokenId] The token id
* @returns The confirmation of the submitted sapling transactions
*/
this.submitSaplingTransaction = async (saplingTransactions, callbacks) => {
// V2: Call Set contracts directly (bypasses Factory)
if (this.contractArchitecture === '2') {
return this.submitSaplingTransactionV2(saplingTransactions, callbacks);
}
// V1: Route through map contract (legacy)
const dappContract = await this.getContract(this.shieldBridgeContractAddress);
const dappContractEstimator = await this.getEstimatorContract(this.shieldBridgeContractAddress);
const saplingMethodObject = saplingTransactions.map((saplingTransaction) => ({
txns: saplingTransaction.saplingTransactions,
contract: saplingTransaction.contract,
token_id: saplingTransaction.tokenId,
}));
const operation = dappContractEstimator.methodsObject.default(saplingMethodObject);
const estimate = await this.tezosClient.estimate.contractCall(operation);
callbacks?.onSigning?.();
return dappContract.methodsObject
.default(saplingMethodObject)
.send({
gasLimit: estimate.gasLimit,
storageLimit: estimate.storageLimit,
fee: estimate.suggestedFeeMutez,
})
.then(async (op) => {
callbacks?.onSubmitting?.({ opHash: op.opHash });
const confirmation = await this.awaitConfirmation(op);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callbacks?.onConfirmed?.({
opHash: op.opHash,
block: confirmation,
});
return { ...confirmation, opHash: op.opHash };
});
};
/**
* @description V2: Submit sapling transactions (unshield/transfer) by calling Set contracts directly.
* No token approvals needed — unshield sends from pool, transfer is state-only.
*/
this.submitSaplingTransactionV2 = async (saplingTransactions, callbacks) => {
const ops = [];
// eslint-disable-next-line no-restricted-syntax
for (const saplingTx of saplingTransactions) {
// eslint-disable-next-line no-await-in-loop
const setAddress = await this.getSetAddress(saplingTx.contract, saplingTx.tokenId);
if (!setAddress) {
const tokenInfo = saplingTx.contract
? `contract ${saplingTx.contract}${saplingTx.tokenId !== undefined ? ` tokenId ${saplingTx.tokenId}` : ''}`
: 'tez';
throw new Error(`Sapling set not found for ${tokenInfo}`);
}
// eslint-disable-next-line no-await-in-loop
const setContract = await this.getContract(setAddress);
ops.push({
method: setContract.methodsObject.default(saplingTx.saplingTransactions),
});
}
// Estimate all operations
const estimateBatch = ops.map(({ method }) => ({
kind: OpKind.TRANSACTION,
...method.toTransferParams(),
}));
const estimates = realignEstimatesForReveal(await this.tezosClient.estimate.batch(estimateBatch), estimateBatch.length);
// Build and send batch
const batch = this.tezosClient.wallet.batch();
ops.forEach(({ method }, i) => {
const estimate = estimates[i];
batch.withContractCall(method, {
gasLimit: estimate.gasLimit,
storageLimit: estimate.storageLimit,
fee: estimate.suggestedFeeMutez,
});
});
callbacks?.onSigning?.();
return batch.send().then(async (op) => {
callbacks?.onSubmitting?.({ opHash: op.opHash });
const confirmation = await this.awaitConfirmation(op);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callbacks?.onConfirmed?.({
opHash: op.opHash,
block: confirmation,
});
return { ...confirmation, opHash: op.opHash };
});
};
/**
* @description Submit sapling withdrawals/unshielding transactions
* @param {SaplingTransactions} saplingWithdrawals Sapling withdrawals/unshielding transactions to be submitted
* @param {string[]} saplingWithdrawals.saplingTransactions The sapling transactions to be submitted
* @param {string} [saplingWithdrawals.contract] The token contract address
* @param {number} [saplingWithdrawals.tokenId] The token id
* @returns The confirmation of the submitted sapling withdrawals/unshielding transactions
*/
this.submitSaplingUnshieldTransaction = async (saplingWithdrawals, callbacks) => this.submitSaplingTransaction(saplingWithdrawals, callbacks);
/**
* @description Submit sapling transfers transactions
* @param {SaplingTransactions} saplingTransfers Sapling transfers to be submitted
* @param {string[]} saplingTransfers.saplingTransactions The sapling transactions to be submitted
* @param {string} [saplingTransfers.contract] The token contract address
* @param {number} [saplingTransfers.tokenId] The token id
* @returns The confirmation of the submitted sapling transfers
*/
this.submitSaplingTransferTransaction = async (saplingTransfers, callbacks) => this.submitSaplingTransaction(saplingTransfers, callbacks);
/**
* @description Construct the sapling parameters for the shielded transaction
* @param shieldParam The sapling shielding parameters
* @param {number} shieldParam.amount The amount to be shielded
* @param {string} [shieldParam.shieldedAddress] The shielded address to apply the shielded tokens
* @param {string} [shieldParam.contract] The token contract address
* @param {number} [shieldParam.tokenId] The token id
* @param {string} [shieldParam.memo] The memo to be included in the sapling transaction
* @returns The sapling parameters for the shielded transaction
*/
this.constructShieldTokenParams = async (shieldParam) => {
const { amount, shieldedAddress, contract, tokenId, memo } = shieldParam;
// Validate amount
validateAmount(amount, 'Shield amount');
return this.withWorker(async (saplingWorker, tokenDecimals) => {
let unitAmount = amount.toString();
if (!this.useBaseUnits) {
unitAmount = toBaseUnits(amount, tokenDecimals);
}
let to = shieldedAddress;
// If no shielded address is provided, default to the loaded sapling payment address
if (!to) {
const saplingPaymentAddress = await saplingWorker.getPaymentAddress();
to = saplingPaymentAddress.address;
}
const saplingTxn = await saplingWorker.prepareShieldedTransaction([
{
to,
amount: unitAmount,
memo,
mutez: true,
},
]);
const owner = await this.tezosClient.wallet.pkh();
return {
saplingTransactions: [saplingTxn].filter((t) => t != null),
owner,
amount: unitAmount,
contract,
tokenId,
};
}, contract, tokenId);
};
/**
* @description Shield the specified amount of unshielded tokens to the sapling address
* @param {ShieldParams} shieldParams Sapling shielding parameters to be constructed into sapling transactions
* @param {number} shieldParams.amount The amount to be shielded
* @param {string} [shieldParams.shieldedAddress] The shielded address to apply the shielded tokens
* @param {string} [shieldParams.contract] The token contract address
* @param {number} [shieldParams.tokenId] The token id
* @param {string} [shieldParams.memo] The memo to be included in the sapling transaction
* @param {TransactionProgressCallbacks} [callbacks] Optional callbacks for operation progress updates
* @returns The confirmation of the submitted sapling shielding transactions
* @throws {Error} If called in view-only mode (with a viewing key)
*/
this.shield = async (shieldParams, callbacks) => {
if (this.isViewOnlyMode) {
throw new Error('Cannot shield tokens in view-only mode. A spending key is required for transaction operations. ' +
'Initialize the SDK with saplingSecret or saplingMnemonic instead of saplingViewingKey.');
}
this.operationsInFlight += 1;
try {
let contractParams = [];
callbacks?.onGenerating?.(shieldParams);
if (this.parallelThreads) {
const shieldParamPromises = shieldParams.map((shieldParam) => this.constructShieldTokenParams(shieldParam));
contractParams = await Promise.all(shieldParamPromises);
}
else {
for (let i = 0; i < shieldParams.length; i += 1) {
const shieldParam = shieldParams[i];
const contractParam =
// eslint-disable-next-line no-await-in-loop
await this.constructShieldTokenParams(shieldParam);
contractParams.push(contractParam);
}
}
return await this.submitSaplingShieldTransaction(contractParams, callbacks);
}
finally {
this.operationsInFlight -= 1;
}
};
/**
* @description Construct the sapling parameters for the unshielded transaction
* @param unshieldParam The sapling unshielding parameters
* @param {number} unshieldParam.amount The amount to be unshielded
* @param {string} [unshieldParam.unshieldedAddress] The unshielded address to apply the unshielded tokens
* @param {string} [unshieldParam.contract] The token contract address
* @param {number} [unshieldParam.tokenId] The token id
* @returns The sapling parameters for the unshielded transaction
*/
this.constructUnshieldTokenParams = async (unshieldParam) => {
const { amount, unshieldedAddress, contract, tokenId } = unshieldParam;
// Validate amount
validateAmount(amount, 'Unshield amount');
return this.withWorker(async (saplingWorker, tokenDecimals) => {
let unitAmount = amount.toString();
if (!this.useBaseUnits) {
unitAmount = toBaseUnits(amount, tokenDecimals);
}
let to = unshieldedAddress;
// If no unshielded address is provided, default to the wallet public key hash
if (!to) {
to = await this.tezosClient.wallet.pkh();
}
const saplingTxn = await saplingWorker.prepareUnshieldedTransaction({
to,
amount: unitAmount,
mutez: true,
});
return {
saplingTransactions: [saplingTxn].filter((t) => t != null),
contract,
tokenId,
};
}, contract, tokenId);
};
/**
* @description Unshield the specified amount of shielded tokens from the sapling address
* @param {UnshieldParams} unshieldParams Sapling unshielding parameters to be constructed into sapling transactions
* @param {number} unshieldParams.amount The amount to be unshielded
* @param {string} [unshieldParams.unshieldedAddress] The unshielded address to apply the unshielded tokens
* @param {string} [unshieldParams.contract] The token contract address
* @param {number} [unshieldParams.tokenId] The token id
* @param {TransactionProgressCallbacks} [callbacks] Optional callbacks for operation progress updates
* @returns The confirmation of the submitted sapling unshielding transactions
* @throws {Error} If called in view-only mode (with a viewing key)
*/
this.unshield = async (unshieldParams, callbacks) => {
if (this.isViewOnlyMode) {
throw new Error('Cannot unshield tokens in view-only mode. A spending key is required for transaction operations. ' +
'Initialize the SDK with saplingSecret or saplingMnemonic instead of saplingViewingKey.');
}
this.operationsInFlight += 1;
try {
let contractParams = [];
callbacks?.onGenerating?.(unshieldParams);
if (this.parallelThreads) {
const unshieldParamPromises = unshieldParams.map((unshieldParam) => this.constructUnshieldTokenParams(unshieldParam));
contractParams = await Promise.all(unshieldParamPromises);
}
else {
for (let i = 0; i < unshieldParams.length; i += 1) {
const unshieldParam = unshieldParams[i];
const contractParam =
// eslint-disable-next-line no-await-in-loop
await this.constructUnshieldTokenParams(unshieldParam);
contractParams.push(contractParam);
}
}
return await this.submitSaplingUnshieldTransaction(contractParams, callbacks);
}
finally {
this.operationsInFlight -= 1;
}
};
/**
* @description Construct the sapling parameters for the transfer transaction
* @param transferParam The sapling transfer parameters
* @param {string} [transferParam.contract] The token contract address
* @param {number} [transferParam.tokenId] The token id
* @param {object} transferParam.transfers The transfers to be made
* @returns The sapling parameters for the transfer transaction
*/
this.constructTransferTokenParams = async (transferParam) => {
const { contract, tokenId, transfers } = transferParam;
// Validate inputs
if (!transfers || !Array.isArray(transfers) || transfers.length === 0) {
throw new Error('Transfers array must not be empty');
}
transfers.forEach((transfer, index) => {
if (!transfer.to || typeof transfer.to !== 'string') {
throw new Error(`Transfer[${index}] recipient address must be a valid string`);
}
validateAmount(transfer.amount, `Transfer[${index}] amount`);
});
return this.withWorker(async (saplingWorker, tokenDecimals) => {
const saplingTransfers = transfers.map(({ amount, to, memo }) => {
let unitAmount = amount.toString();
if (!this.useBaseUnits) {
unitAmount = toBaseUnits(amount, tokenDecimals);
}
return {
to,
amount: unitAmount,
memo,
mutez: true,
};
});
const saplingTxn = await saplingWorker.prepareSaplingTransaction(saplingTransfers);
return {
saplingTransactions: [saplingTxn].filter((t) => t != null),
contract,
tokenId,
};
}, contract, tokenId);
};
/**
* @description Transfer the specified amount of shielded tokens to the specified shielded address
* @param {TransferParams[]} transferParams Sapling transfer parameters to be constructed into sapling transactions
* @param {string} [transferParams.contract] The token contract address
* @param {number} [transferParams.tokenId] The token id
* @param {object} transferParams.transfers The transfers to be made
* @param {TransactionProgressCallbacks} [callbacks] Optional callbacks for operation progress updates
* @returns The confirmation of the submitted sapling transfer transactions
* @throws {Error} If called in view-only mode (with a viewing key)
*/
this.transfer = async (transferParams, callbacks) => {
if (this.isViewOnlyMode) {
throw new Error('Cannot transfer tokens in view-only mode. A spending key is required for transaction operations. ' +
'Initialize the SDK with saplingSecret or saplingMnemonic instead of saplingViewingKey.');
}
this.operationsInFlight += 1;
try {
let contractParams = [];
callbacks?.onGenerating?.(transferParams);
if (this.parallelThreads) {
const unshieldParamPromises = transferParams.map((transferParam) => this.constructTransferTokenParams(transferParam));
contractParams = await Promise.all(unshieldParamPromises);
}
else {
for (let i = 0; i < transferParams.length; i += 1) {
const transferParam = transferParams[i];
const contractParam =
// eslint-disable-next-line no-await-in-loop
await this.constructTransferTokenParams(transferParam);
contractParams.push(contractParam);
}
}
return await this.submitSaplingTransferTransaction(contractParams, callbacks);
}
finally {
this.operationsInFlight -= 1;
}
};
/**
* @description Get the shielded sapling token balance for the currently loaded shielded address
* @param {SaplingTokenInfo} saplingTokenInfo The sapling token information
* @param {string} [saplingTokenInfo.contract] The token contract address
* @param {number} [saplingTokenInfo.tokenId] The token id
* @param {string} [saplingTokenInfo.setAddress] The set contract address
* @returns The shielded sapling token balance for the currently loaded shielded address
*/
this.getShieldedBalance = async ({ contract, tokenId, setAddress, decimals, }) => this.withWorker(async (saplingWorker, tokenDecimals) => {
const balance = (await saplingWorker.getSaplingBalance());
if (this.useBaseUnits) {
return balance;
}
return new BigNumber(balance)
.dividedBy(new BigNumber(10).exponentiatedBy(tokenDecimals))
.toNumber();
}, contract, tokenId, setAddress, undefined, decimals);
/**
* @description Evict this account's incremental balance cache (the v2 decrypt cache, which
* holds decrypted notes). Call this when forgetting/locking an account so no decrypted data is
* left at rest. No-op when the balance cache is disabled or unavailable.
*/
this.clearShieldedBalanceCache = async () => {
await this.ready;
await this.withWorker(async (saplingWorker) => {
await saplingWorker.clearShieldedBalanceCache();
});
};
// ---------------------------------------------------------------------------
// Factory contract on-chain view methods (V2 only)
// ---------------------------------------------------------------------------
/**
* @description Get the Tez set contract address from the factory
* @returns The address of the Tez sapling set contract
* @throws If the factory contract doesn't have a Tez set or if not using V2 architecture
*/
this.getTezSetAddress = async () => {
if (this.contractArchitecture !== '2') {
throw new Error('Factory views are only available with V2 architecture');
}
const factoryContract = await this.getEstimatorContract(this.shieldBridgeContractAddress);
const tezSetAddress = await factoryContract.contractViews
.get_tez_set()
.executeView({ viewCaller: this.shieldBridgeContractAddress });
return tezSetAddress;
};
/**
* @description Get the FA1.2 set contract address for a given token from the factory
* @param tokenContract The FA1.2 token contract address
* @returns The address of the FA1.2 sapling set contract, or undefined if not registered
* @throws If not using V2 architecture
*/
this.getFA12SetAddress = async (tokenContract) => {
if (this.contractArchitecture !== '2') {
throw new Error('Factory views are only available with V2 architecture');
}
const factoryContract = await this.getEstimatorContract(this.shieldBridgeContractAddress);
const result = await factoryContract.contractViews
.get_fa1_2_set(tokenContract)
.executeView({ viewCaller: this.shieldBridgeContractAddress });
// On-chain view returns option<address> — Taquito represents None as undefined
return result ?? undefined;
};
/**
* @description Get the FA2 set contract address for a given token and token ID from the factory
* @param tokenContract The FA2 token contract address
* @param tokenId The FA2 token ID
* @returns The address of the FA2 sapling set contract, or undefined if not registered
* @throws If not using V2 architecture
*/
this.getFA2SetAddress = async (tokenContract, tokenId) => {
if (this.contractArchitecture !== '2') {
throw new Error('Factory views are only available with V2 architecture');
}
const factoryContract = await this.getEstimatorContract(this.shieldBridgeContractAddress);
const result = await factoryContract.contractViews
.get_fa2_set({ contract: tokenContract, token_id: tokenId })
.executeView({ viewCaller: this.shieldBridgeContractAddress });
// On-chain view returns option<address> — Taquito represents None as undefined
return result ?? undefined;
};
/**
* @description Check if a set contract address was deployed by this factory
* @param setAddress The set contract address to verify
* @returns true if the address is a registered set contract deployed by this factory
* @throws If not using V2 architecture
*/
this.isRegisteredSet = async (setAddress) => {
if (this.contractArchitecture !== '2') {
throw new Error('Factory views are only available with V2 architecture');
}
const factoryContract = await this.getEstimatorContract(this.shieldBridgeContractAddress);
return factoryContract.contractViews
.is_registered_set(setAddress)
.executeView({ viewCaller: this.shieldBridgeContractAddress });
};
/**
* @description Get all the shielded sapling tokens
* @param includeMetadata Include the metadata for the shielded sapling tokens
* @returns The shielded sapling tokens with their set contract addresses
*
* @note This method uses TzKT API to enumerate big maps in the factory storage.
* For individual token lookups, use getSetAddress() which uses RPC directly.
* Big maps cannot be enumerated via RPC without knowing the keys.
*/
this.getAllShieldedAssets = async (includeMetadata = false) => {
// Get factory storage to retrieve big map IDs
const factoryContract = await this.getEstimatorContract(this.shieldBridgeContractAddress);
const factoryStorage = await factoryContract.storage();
const setAddresses = [];
// Add TEZ set if it exists
const tezAddress = factoryStorage.tez || undefined;
if (tezAddress) {
setAddresses.push({
setAddress: tezAddress,
});
// Cache tez set address
this.setAddressCache.set('tez', Promise.resolve(tezAddress));
}
// Enumerate FA2 and FA1.2 token sets from big maps in parallel
const fa2BigMapId = factoryStorage.token_fa_2;
const fa12BigMapId = factoryStorage.token_fa_1_2;
const [fa2Keys, fa12Keys] = await Promise.all([
fetch(`${this.tzktBaseUrl}/v1/bigmaps/${fa2BigMapId}/keys?active=true`).then((res) => res.json()),
fetch(`${this.tzktBaseUrl}/v1/bigmaps/${fa12BigMapId}/keys?active=true`).then((res) => res.json()),
]);
fa2Keys.forEach(({ key, value }) => {
const tokenId = parseInt(key.nat, 10);
setAddresses.push({
setAddress: value,
contract: key.address,
tokenId,
});
// Cache FA2 token set address
const cacheKey = `${key.address}:${tokenId}`;
this.setAddressCache.set(cacheKey, Promise.resolve(value));
});
fa12Keys.forEach(({ key: contract, value: setAddress }) => {
setAddresses.push({ setAddress, contract });
// Cache FA1.2 token set address
this.setAddressCache.set(contract, Promise.resolve(setAddress));
});
if (!includeMetadata) {
return setAddresses;
}
const withMetadata = await Promise.all(setAddresses.map(async (asset) => {
if (asset.contract) {
try {
const tokenMetadata = await this.getTokenMetadata(asset.contract, asset.tokenId);
return { ...asset, metadata: tokenMetadata };
}
catch {
// Gracefully degrade — one token's missing metadata shouldn't break the list
return { ...asset, metadata: undefined };
}
}
return asset;
}));
return withMetadata;
};
/**
* @description Get the shielded sapling token balances for all the sapling tokens
* @returns The shielded sapling token balances for all the sapling tokens
*/
this.getAllShieldedBalances = async () => {
const setAssets = await this.getAllShieldedAssets();
if (this.parallelThreads) {
// Parallel: query all balances concurrently
const results = await Promise.all(setAssets.map(async (asset) => {
const balance = await this.getShieldedBalance(asset);
return { ...asset, balance };
}));
return results;
}
// Sequential: query balances one at a time
const balances = [];
for (let i = 0; i < setAssets.length; i += 1) {
const asset = setAssets[i];
// eslint-disable-next-line no-await-in-loop
const balance = await this.getShieldedBalance(asset);
balances.push({ ...asset, balance });
}
return balances;
};
/**
* @description Get the shielded incoming and outgoing transactions for the specified sapling contract and token id
* @param {string} [contract] Sapling contract address
* @param {number} [tokenId] Token id
* @returns The shielded incoming and outgoing transactions for the specified sapling contract and token id
*/
this.getShieldedTransactions = async (contract, tokenId) => this.withWorker(async (saplingWorker, tokenDecimals) => {
const transactions = await saplingWorker.getSaplingTransactions();
return {
incoming: transactions.incoming.map((transaction) => {
if (this.useBaseUnits) {
return transaction;
}
const value = new BigNumber(transaction.value)
.dividedBy(new BigNumber(10).exponentiatedBy(tokenDecimals))
.toNumber();
return { ...transaction, value };
}),
outgoing: transactions.outgoing.map((transaction) => {
if (this.useBaseUnits) {
return transaction;
}
const value = new BigNumber(transaction.value)
.dividedBy(new BigNumber(10).exponentiatedBy(tokenDecimals))
.toNumber();
return { ...transaction, value };
}),
};
}, contract, tokenId);
/**
* @description Get the sapling payment address of the currently loaded sapling key
* @returns The sapling payment address
*/
this.getShieldedAddress = async () => {
// Deterministic in the loaded key — derive once and reuse.
if (this.shieldedAddressPromise) {
return this.shieldedAddressPromise;
}
const addressPromise = (async () => {
// Generating a shielded address doesn't require fetching the set address
// or loading blockchain state - we just need the sapling secret/mnemonic
await this.ready;
let { saplingWorker } = this;
let poolEntry = null;
if (this.workerPool) {
poolEntry = await this.workerPool.checkout();
saplingWorker = poolEntry.worker;
}
try {
const { sk, skType } = this.getSaplingKeyInfo();
// Load just the sapling secret without contract state
// Use a dummy contract address since we're only generating the address
await saplingWorker.loadSaplingSecret({
sk,
skType,
saplingDetails: {
contractAddress: 'KT1Dummy', // Dummy address - not used for address generation
memoSize: 8,
},
rpcUrl: this.tezosClient.rpc.getRpcUrl(),
});
const saplingPaymentAddress = await saplingWorker.getPaymentAddress();
return saplingPaymentAddress.address;
}
finally {
if (poolEntry) {
this.workerPool?.release(poolEntry);
}
}
})();
// Keep retryable on failure (mirrors the cache delete-on-error pattern).
addressPromise.catch(() => {
if (this.shieldedAddressPromise === addressPromise) {
this.shieldedAddressPromise = undefined;
}
});
this.shieldedAddressPromise = addressPromise;
return addressPromise;
};
/**
* Switch contract architecture without re-initializing sapling keys.
*
* This allows seamless migration between V1 (Map) and V2 (Factory) contracts
* while preserving the user's sapling account. The shielded address remains
* the same since it's derived from the mnemonic, not the contract.
*
* @param architecture - '1' for Map (legacy), '2' for Factory (recommended)
* @param contractAddress - Optional custom contract address override
*
* @example
* ```typescript
* // Switch to V1 to access legacy funds
* sdk.switchArchitecture('1');
* await sdk.getShieldedBalance('V1_CONTRACT_ADDRESS');
*
* // Switch back to V2 for new transactions
* sdk.switchArchitecture('2');
* ```
*/
this.switchArchitecture = (architecture, contractAddress) => {
// Guard against switching while operations are in flight
if (this.operationsInFlight > 0) {
throw new Error(`Cannot switch architecture while ${this.operationsInFlight} operation(s) are in flight. ` +
'Wait for all pending operations to complete before switching.');
}
// Update architecture setting
this.contractArchitecture = architecture;
// Update contract address
if (contractAddress) {
this.shieldBridgeContractAddress = contractAddress;
}
else if (architecture === '1') {
this.shieldBridgeContractAddress = saplingMapContract[this.network];
}
else {
this.shieldBridgeContractAddress =
shieldBridgeContractAddresses[this.network];
}
// Clear architecture-specific caches. Set addresses (V2) and sapling IDs
// (V1) are resolved against the now-changed contract, so their caches must
// be dropped or they'd return stale promises that never refetch. The
// factory storage snapshot is likewise architecture-bound. Token decimals
// and metadata are keyed on the token contract (architecture-independent)
// and are deliberately preserved.
this.walletContractCache.clear();
this.estimatorContractCache.clear();
this.setAddressCache.clear();
this.saplingIdCache.clear();
this.factoryStoragePromise = null;
console.log(`[ShieldBridgeSDK] Switched to ${architecture === '1' ? 'V1 (Map)' : 'V2 (Factory)'}: ${this.shieldBridgeContractAddress}`);
};
/**
* Get the current contract architecture version.
*
* @returns '1' for Map (legacy) or '2' for Factory (recommended)
*/
this.getArchitecture = () => this.contractArchitecture;
/**
* @description Export the viewing key for the currently loaded sapling key
*
* The viewing key can be used to initialize the SDK in view-only mode, allowing
* read-only operations (balance queries, transaction history) without exposing
* the spending key. This is useful for:
* - Auditing and compliance purposes
* - Sharing balance visibility without spending ability
* - Creating monitoring applications
*
* @returns The viewing key as a hex string
* @throws {Error} If no spending key or viewing key is loaded
*
* @example
* // Export viewing key from spending key
* const viewingKey = await sdk.getViewingKey();
*
* // Use it to create a view-only SDK instance
* const viewOnlySdk = new ShieldBridgeSDK({
* client: tezos,
* saplingViewingKey: viewingKey
* });
*
* // Now you can query balances without spending ability
* const balance = await viewOnlySdk.getShieldedBalance({});
*/
this.getViewingKey = async () => {
// Deterministic in the loaded key — derive once and reuse.
if (this.viewingKeyPromise) {
return this.viewingKeyPromise;
}
const keyPromise = (async () => {
// Viewing key is derived purely from the secret/mnemonic — no on-chain state needed.
// Use a lightweight worker that doesn't load the full sapling blockchain state.
let { saplingWorker } = this;
let poolEntry = null;
if (this.workerPool) {
poolEntry = await this.workerPool.checkout();
saplingWorker = poolEntry.worker;
}
if (!saplingWorker) {
throw new Error('Sapling worker not initialized');
}
try {
const { sk, skType } = this.getSaplingKeyInfo();
await saplingWorker.loadSaplingSecret({
sk,
skType,
// Dummy contract details — viewing key derivation doesn't access the chain
saplingDetails: {
contractAddress: this.shieldBridgeContractAddress,
memoSize: 8,
},
rpcUrl: this.tezosClient.rpc.getRpcUrl(),
});
return await saplingWorker.getViewingKey();
}
finally {
if (poolEntry) {
this.workerPool?.release(poolEntry);
}
}
})();
// Keep retryable on failure.
keyPromise.catch(() => {
if (this.viewingKeyPromise === keyPromise) {
this.viewingKeyPromise = undefined;
}
});
this.viewingKeyPromise = keyPromise;
return keyPromise;
};
/**
* @description Clean up all workers and clear caches.
* Call this when the SDK instance is no longer needed to prevent memory leaks,
* especially in single-page applications where components may mount/unmount.
*/
this.destroy = async () => {
// Destroy the worker pool first (terminates all pooled workers)
if (this.workerPool) {
this.workerPool.destroy();
this.workerPool = null;
}
// Terminate the primary (non-pooled) worker
try {
if (this.saplingWorker) {
this.saplingWorker[Comlink.releaseProxy]();
}
}
catch {
// Worker may already be terminated
}
this.setAddressCache.clear();
this.saplingIdCache.clear();
this.tokenDecimalsCache.clear();
this.tokenMetadataCache.clear();
this.walletContractCache.clear();
this.estimatorContractCache.clear();
this.factoryStoragePromise = null;
this.shieldedAddressPromise = undefined;
this.viewingKeyPromise = undefined;
// Zero out the secret key material so it cannot be recovered from memory
__classPrivateFieldSet(this, _ShieldBridgeSDK_saplingKeyInfo, { skType: 'secretKey', sk: '' }, "f");
};
/**
* @description Initialize the sapling set for the specified token contract and token id
* @param {string} contract The token contract address
* @param {number} [tokenId] The token id
* @returns The confirmation of the initialized sapling set
*/
this.initTokenSaplingSet = async (contract, tokenId) => {
const dappContract = await this.getContract(this.shieldBridgeContractAddress);
return dappContract.methodsObject
.init_token_sapling_set({
contract,
token_id: tokenId,
})
.send()
.then(async (op) => {
const confirmation = await this.awaitConfirmation(op);
return { ...confirmation, opHash: op.opHash };
});
};
this.tezosClient = config.client;
this.minConfirmations = config.minConfirmations ?? 1;
// ── Extract and protect secrets immediately ──
if (config.saplingSecret) {
__classPrivateFieldSet(this, _ShieldBridgeSDK_saplingKeyInfo, { skType: 'secretKey', sk: config.saplingSecret }, "f");
}
else if (config.saplingViewingKey) {
__classPrivateFieldSet(this, _ShieldBridgeSDK_saplingKeyInfo, {
skType: 'viewingKey',
sk: config.saplingViewingKey,
}, "f");
}
else if (config.saplingMnemonic) {
__classPrivateFieldSet(this, _ShieldBridgeSDK_saplingKeyInfo, { skType: 'mnemonic', sk: config.saplingMnemonic }, "f");
}
else {
throw new Error('One of saplingSecret, saplingMnemonic, or saplingViewingKey must be provided.');
}
// Extract non-secret config values we need after construction
this.saplingParamsUrl = config.saplingParamsUrl;
this.saplingDiffCache = config.saplingDiffCache ?? true;
this.saplingBalanceCache = config.saplingBalanceCache ?? false;
this.saplingDiffStore = config.saplingDiffStore;
this.network = (config.tzktApi || 'mainnet');
// Determine contract architecture (V2 is default)
this.contractArchitecture = config.contractArchitecture ?? '2';
// Warn if using deprecated V1 architecture
if (this.contractArchitecture === '1') {
console.warn('[ShieldBridgeSDK] V1 (Map contract) architecture is deprecated. ' +
'Please migrate to V2 (Factory contract) for new transactions. ' +
'V1 support is provided for fund migration only.');
}
// Select contract address based on architecture
// Accepts shieldBridgeContract (preferred), saplingFactoryContract, saplingMapContract, or saplingStateMapContract (all deprecated aliases)
if (this.contractArchitecture === '1') {
// V1: Use map contract
this.shieldBridgeContractAddress =
config.shieldBridgeContract ??
config.saplingMapContract ??
config.saplingStateMapContract ??
saplingMapContract[config.tzktApi || 'mainnet'];
}
else {
// V2: Use factory contract
this.shieldBridgeContractAddress =
config.shieldBridgeContract ??
config.saplingFactoryContract ??
config.saplingStateMapContract ??
shieldBridgeContractAddresses[config.tzktApi || 'mainnet'];
}
this.useBaseUnits = config.useBaseUnits ?? false;
this.maxPoolSize =
typeof config.parallelThreads === 'number'
? Math.max(1, Math.min(config.parallelThreads, DEFAULT_POOL_SIZE))
: DEFAULT_POOL_SIZE;
this.parallelThreads =
typeof config.parallelThreads === 'number'
? true
: (config.parallelThreads ?? true);
this.isViewOnlyMode = !!config.saplingViewingKey;
this.tzktBaseUrl = tzktApiMap[this.network];
this.ready = this.initializeSaplingWorker();
// ── Scrub secrets from the config object so they cannot leak ──
// Even if the caller retains a reference to the config, the secrets
// lived on an object literal that is now cleaned.
// eslint-disable-next-line no-param-reassign
delete config.saplingSecret;
// eslint-disable-next-line no-param-reassign
delete config.saplingMnemonic;
// eslint-disable-next-line no-param-reassign
delete config.saplingViewingKey;
}
/**
* @description Format token info for error messages
*/
static formatTokenInfo(contract, tokenId) {
if (!contract)
return 'tez';
return `contract ${contract}${tokenId !== undefined ? ` tokenId ${tokenId}` : ''}`;
}
}
_ShieldBridgeSDK_saplingKeyInfo = new WeakMap();
//# sourceMappingURL=index.js.map