@zerc20/client-sdk
v0.4.4
Published
Client SDK for interacting with ZERC20 rollup services from browser or node environments.
Readme
@zerc20/client-sdk
Note: This package was previously published as
zerc20-client-sdk. Versions up to 0.3.3 remain available under the old name, and 0.4.0+ are published here under@zerc20/client-sdk. Please update your dependency to@zerc20/client-sdk.
Client SDK for interacting with zERC20 privacy-preserving token system from browser or Node.js environments.
Overview
zERC20 is a privacy-preserving, cross-chain ERC-20 token system that enables:
- Private transfers using zero-knowledge proofs (Nova and Groth16 circuits)
- Stealth transactions via a messaging layer on the Internet Computer (ICP)
- Cross-chain functionality using LayerZero as the interoperability protocol
This SDK provides TypeScript bindings for all client-side operations.
Installation
npm install @zerc20/client-sdkRequirements
- Node.js >= 18
- Browser with WebAssembly support
Compatibility Notes
- This package is ESM-only (
"type": "module"). CommonJS (require) is not supported. - HTTP-based modules (e.g. Decider client, LayerZero Scan) rely on
fetch.- Node.js 18+ includes
fetchglobally. - In custom runtimes, provide a compatible
fetchimplementation where needed.
- Node.js 18+ includes
Provider Abstraction (Library/Framework Agnostic)
The SDK public API is designed to be wallet/provider agnostic:
- Read paths accept
EvmReadProvider - Write paths accept
EvmWriteProvider - UI frameworks are not part of the SDK surface
This lets integrators use viem, ethers, web3.js, or custom adapters without depending on React/Vue internals.
import type { EvmReadProvider, EvmWriteProvider } from "@zerc20/client-sdk";Quick Start
import { createSdk, preparePrivateSend, scanReceivings } from "@zerc20/client-sdk";
// Initialize SDK
const sdk = createSdk({
// configuration options
});
// Create a stealth client for ICP communication
const client = sdk.createStealthClient({
agent,
storageCanisterId,
keyManagerCanisterId,
});
// Prepare a private send
const prepared = await preparePrivateSend({
client,
seed,
recipientChainId,
recipientAddress,
// ...
});
// Scan for received payments
const announcements = await scanReceivings({
client,
vetKey,
// ...
});Architecture
src/
├── ic/ # Internet Computer (ICP) integration
│ ├── client.ts # StealthCanisterClient for canister communication
│ ├── idl.ts # Candid IDL factory definitions
│ ├── encryption.ts # IBE + AES-GCM encryption
│ ├── authorization.ts # EIP-191 signing & VetKD authorization
│ └── keys.ts # View key derivation
├── operations/ # High-level protocol operations
│ ├── privateSend.ts # Private payment flow
│ ├── receive/ # Receive/scan announcements
│ ├── invoice.ts # Invoice creation
│ ├── teleport.ts # Single teleport proof
│ ├── teleportProof.ts # Batch teleport proof (Nova + Decider)
│ ├── relay/ # Relay node HTTP client (redeem/swap)
│ ├── liquidityManager/ # Wrap/unwrap with slippage protection
│ └── layerzeroScan/ # LayerZero message tracking & decoding
├── wasm/ # WASM runtime & bindings
│ ├── index.ts # WasmRuntime class
│ ├── loader.ts # WASM bindings loader (browser/Node.js)
│ └── serialization.ts # Type conversion utilities
├── zkp/ # Zero-knowledge proof orchestration
├── decider/ # Decider proof generation (HTTP client)
├── chain/ # Chain metadata (names, explorers, aliases)
├── registry/ # Token & hub configuration
├── onchain/ # Contract interaction (ABI decoding, token reads)
└── utils/ # Shared utilitiesKey Modules
WASM Configuration
Configure the WASM runtime for cryptographic operations:
import { configureWasmLocator } from "@zerc20/client-sdk";
// Point to your hosted WASM binary
configureWasmLocator({ url: "/zerc20_wasm_bg.wasm" });StealthCanisterClient
Communicates with ICP canisters for announcements and invoices:
import { StealthCanisterClient } from "@zerc20/client-sdk";
const client = new StealthCanisterClient(agent, storageCanisterId, keyManagerCanisterId);
// Submit an announcement
await client.submitAnnouncement(announcementInput);
// List announcements
const page = await client.listAnnouncements(startAfter, limit);Liquidity Manager
Wrap and unwrap tokens with slippage protection:
import {
unwrapWithLiquidityManager,
buildCrossUnwrapQuote,
applySlippage,
} from "@zerc20/client-sdk";
// Local unwrap with slippage protection
await unwrapWithLiquidityManager({
writeProvider,
readProvider,
liquidityManagerAddress,
zerc20TokenAddress,
amount: 1000n,
minAmountOut: 950n, // Reject if output < 950
});
// Cross-chain unwrap with slippage tolerance (basis points)
const quote = await buildCrossUnwrapQuote({
sourceToken,
destinationToken,
amount: 1000n,
account,
readProviderSource: sourceReadProvider,
readProviderDestination: destReadProvider,
slippageBps: 50, // 0.5% slippage tolerance
});Relay Operations
Interact with a relay node for relayer-based redeem and token-to-native swap flows:
import {
estimateRelayFee,
fetchSwapQuote,
submitRelaySwap,
submitRelayTeleport,
} from "@zerc20/client-sdk";
const fee = await estimateRelayFee("https://relay.example", 1);
const quote = await fetchSwapQuote("https://relay.example", { chainId: 1, amount: 1_000_000n });
if (quote.priceFallback) {
console.warn("Relay is using fallback oracle prices");
}
await submitRelaySwap("https://relay.example", {
chainId: 1,
tokenAmount: 1_000_000n,
minNativeAmount: 900_000n,
maxNativeAmount: quote.nativeAmount,
recipient: "0x...",
owner: "0x...",
permitDeadline: 1_700_000_000n,
permitV: 27,
permitR: "0x...",
permitS: "0x...",
});Blocklist
Check whether an address is on the OFAC sanctions blocklist before executing a private send. This prevents funds from being permanently locked when the recipient cannot redeem:
import { isBlockedAddress } from "@zerc20/client-sdk";
const blocked = await isBlockedAddress(readProvider, blocklistAddress, recipientAddress);
if (blocked) {
throw new Error("Recipient is on the OFAC sanctions blocklist");
}Adaptor Withdraw (Stuck Fund Recovery)
When a cross-chain unwrap fails (e.g. due to Stargate liquidity shortage), user funds may remain in the destination chain's Adaptor contract. The SDK provides functions to detect and recover these stuck funds:
import {
fetchAdaptorBalances,
hasStuckFunds,
withdrawFromAdaptor,
NATIVE_TOKEN_ADDRESS,
} from "@zerc20/client-sdk";
// Check if a user has stuck funds in an adaptor
const stuck = await hasStuckFunds({
provider: readProvider,
account: "0xUser...",
adaptorAddress: "0xAdaptor...",
});
// Fetch detailed balances
const balances = await fetchAdaptorBalances({
provider: readProvider,
account: "0xUser...",
adaptorAddress: "0xAdaptor...",
});
// balances.underlyingTokenBalance, balances.zerc20Balance, balances.nativeBalance
// Withdraw stuck funds
const result = await withdrawFromAdaptor({
writeProvider,
adaptorAddress: "0xAdaptor...",
token: balances.underlyingTokenAddress, // or zerc20TokenAddress, or NATIVE_TOKEN_ADDRESS
amount: balances.underlyingTokenBalance,
});LayerZero Scan
Track and decode LayerZero cross-chain messages. Configuration and providers are injected as parameters, making this module framework-agnostic:
import {
fetchWalletStatus,
type FetchWalletStatusParams,
type LayerZeroScanConfig,
} from "@zerc20/client-sdk";
const scanConfig: LayerZeroScanConfig = {
baseUrl: "https://scan.layerzero-api.com/v1/",
apiKey: "your-api-key", // optional
};
const result = await fetchWalletStatus({
address: "0xuser",
tokens,
scanConfig,
createReadProvider: (token) => createPublicClient({ chain: ... }),
limit: 10,
filterByToken: true,
});
// result.items: LayerZeroMessageSummary[]
// result.walletUrl: string (link to LZ Scan)
// result.nextToken: string | undefined (pagination cursor)Notes:
fetchWalletStatuscan use transaction input (getTransaction) and receipt logs (getTransactionReceipt) when available.- If those methods are unavailable, decoding still falls back to payload-based paths where possible.
Seed Derivation
Derive a seed from a wallet signature. The SDK handles the message retrieval, signature validation, and keccak256 hashing internally, while accepting a wallet-agnostic sign function:
import { deriveSeed } from "@zerc20/client-sdk";
// Works with any wallet library (wagmi, ethers.js, web3.js, etc.)
const seed = await deriveSeed(async (message) => {
// Return the signature as a 0x-prefixed hex string (65 bytes for personal_sign)
return wallet.signMessage(message);
});The function validates that the returned signature is a valid hex string of exactly 65 bytes before hashing. Invalid signatures throw descriptive errors to prevent silent seed misderivation.
Chain Metadata
Look up chain display names, short labels, block explorer URLs, and resolve chain name aliases — all from a single source of truth shared across products:
import {
getChainMetadata,
getChainDisplayName,
getExplorerTxUrl,
resolveChainId,
resolveNetworkDisplayName,
} from "@zerc20/client-sdk";
getChainDisplayName(42161); // "Arbitrum"
getChainDisplayName(999999); // "Chain 999999"
getExplorerTxUrl(42161, "0xabc..."); // "https://arbiscan.io/tx/0xabc..."
resolveChainId("arb-sepolia"); // 421614
resolveNetworkDisplayName("arb-sepolia"); // "Arbitrum Sepolia"Token Loading with RPC Overrides
Load and normalize token configuration with runtime RPC URL overrides (e.g. environment-specific Alchemy/Infura endpoints):
import { normalizeTokensWithOverrides } from "@zerc20/client-sdk";
import tokensJson from "./tokens.json";
const tokens = normalizeTokensWithOverrides(
tokensJson as TokensFile, // snake_case JSON is accepted as-is
{
tokens: {
"arb-mainnet": ["https://arb-mainnet.g.alchemy.com/v2/KEY"],
"base-mainnet": ["https://base-mainnet.g.alchemy.com/v2/KEY"],
},
hub: ["https://base-mainnet.g.alchemy.com/v2/KEY"],
},
);Without overrides, normalizeTokensWithOverrides(file) behaves identically to normalizeTokens(file).
Redeem Flow
Prepare a redeem transaction from a collected redeem context. The SDK handles single vs batch proof selection, GeneralRecipient construction, and contract call parameter assembly internally:
import {
collectRedeemContext,
prepareRedeemTransaction,
createTeleportProofClient,
HttpDeciderClient,
} from "@zerc20/client-sdk";
// Initialize proof client and decider
const teleportProofClient = createTeleportProofClient();
const decider = new HttpDeciderClient("https://decider.example.com");
// 1. Collect redeem context (eligible events, proofs, etc.)
const redeemContext = await collectRedeemContext({ burn, tokens, hub, verifierContract, indexerUrl });
// 2. Prepare the transaction (SDK picks single vs batch automatically)
const tx = await prepareRedeemTransaction({
redeemContext,
burn,
teleportProofClient,
decider, // required only when eligible.length > 1
});
// 3. Submit via your wallet library
const hash = await walletClient.writeContract({
address: tx.address as `0x${string}`,
abi: tx.abi,
functionName: tx.functionName,
args: tx.args,
account,
chain,
});The returned RedeemTransaction is pure data ({ address, abi, functionName, args, mode }) with no wallet dependency.
Per-chain error tolerance
collectRedeemContext (and the lower-level fetchTransferEvents /
getAnnouncementStatus) accepts an optional chainErrorPolicy:
"fail-fast"(default, unchanged): any chain's failure rejects the whole call. Use when you need strict all-or-nothing semantics."log-and-continue": each token is queried independently in parallel. A chain that fails (e.g. transient RPC outage on a chain where the burn recipient holds no balance) is logged viaconsole.warnwith thechainIdand yields an empty event list for that chain id; events from other chains are still returned. Recommended for long-running pollers (e.g. redeem bots) where one unreachable chain should not block redemption of events on healthy chains. Any value other than"fail-fast"/"log-and-continue"is rejected at call time so a typo from a JS caller cannot silently switch to data-dropping behavior.
const redeemContext = await collectRedeemContext({
burn,
tokens,
hub,
verifierContract,
indexerUrl,
chainErrorPolicy: "log-and-continue", // opt in
});
// Each per-chain entry carries a `failed` flag; the top-level result also
// exposes the aggregate list. Callers MUST consult these before deriving
// any terminal classification (e.g. orphan detection) from totals.
//
// `failedChainIds` is typed as optional for source-compat with downstream
// fixtures, but the SDK always populates it (defaulting to `[]`).
if ((redeemContext.failedChainIds ?? []).length > 0) {
// One or more chains were unreachable this cycle. Empty events on those
// chains are inconclusive — do NOT classify the announcement as orphan
// based on `eligible=0 && pending=0`; retry on the next cycle instead.
}Orphan-detection caveat
A failed chain is retried normally on the next call, but within the
single failing call, that chain's empty events are indistinguishable from
"no events" unless you check failedChainIds / chains[i].failed. If a
caller drives orphan / terminal classification off
totalIndexedValue === 0 together with an age threshold, a real
cross-chain announcement on a transiently-unreachable chain can be
mis-classified as orphan and have its cursor advanced. Callers running
orphan detection alongside "log-and-continue" MUST suppress the
classification when failedChainIds is non-empty (or when the relevant
chains[i].failed is true). The "fail-fast" default is safe under
orphan logic because it rejects the whole call when any chain fails.
Manual batch flow with UI progress
For batch redeems, callers can use the 2-step proof API to insert UI updates between Nova and Decider steps:
import {
collectRedeemContext,
buildBatchRedeemTransaction,
createTeleportProofClient,
HttpDeciderClient,
} from "@zerc20/client-sdk";
const teleportProofClient = createTeleportProofClient();
const decider = new HttpDeciderClient("https://decider.example.com");
const redeemContext = await collectRedeemContext({ burn, tokens, hub, verifierContract, indexerUrl });
// Step 1: Nova proof
const novaResult = await teleportProofClient.createNovaProof({
aggregationState: redeemContext.aggregationState,
recipientFr: burn.generalRecipient.fr,
secretHex: burn.secret,
events: redeemContext.events.eligible,
proofs: redeemContext.globalProofs,
});
updateUI("Requesting decider proof…"); // ← insert UI update here
// Step 2: Decider proof
const deciderProof = await teleportProofClient.requestDeciderProof(decider, novaResult.ivcProof);
// Step 3: Build transaction
const tx = buildBatchRedeemTransaction({ redeemContext, burn, deciderProof });LayerZero Decode Utilities
Decode LayerZero OFT transaction data — send() calldata, OFTSent event logs, and BridgeRequest compose messages:
import {
decodeSendPayload,
extractOftSentAmount,
decodeBridgeRequest,
} from "@zerc20/client-sdk";
// Decode send() transaction input
const payload = decodeSendPayload(txData);
// → { dstEid, to, amountLD, minAmountLD, composeMsg }
// Extract amountReceivedLD from OFTSent event logs
const amount = extractOftSentAmount(receipt.logs);
// Decode a BridgeRequest compose message (returns null on failure)
const bridgeReq = decodeBridgeRequest(payload.composeMsg);
// → { dstEid, to, refundAddress, minAmountOut } | nullProof Generation
import {
HttpDeciderClient,
createTeleportProofClient,
} from "@zerc20/client-sdk";
const proofClient = createTeleportProofClient();
// Single teleport proof (Groth16)
const singleProof = await proofClient.createSingleTeleportProof(/* ... */);
// Batch teleport proof (Nova + Decider) — two-step API
const novaResult = await proofClient.createNovaProof({
aggregationState,
recipientFr,
secretHex,
events,
proofs,
});
// Callers can update UI between steps (e.g. progress indicators)
const decider = new HttpDeciderClient("https://decider.example.com");
const deciderProof = await proofClient.requestDeciderProof(decider, novaResult.ivcProof);Development
Build
npm run buildTest
npm testType Check
npm run typecheckSkipped Tests
src/zkp/__tests__/runNovaProver.test.ts- Skipped when artifact files are missing
- Artifacts are expected under
public/artifacts/:withdraw_local_groth16_pk.binwithdraw_local_groth16_vk.binwithdraw_global_groth16_pk.binwithdraw_global_groth16_vk.binwithdraw_local_nova_pp.binwithdraw_local_nova_vp.binwithdraw_global_nova_pp.binwithdraw_global_nova_vp.bin
src/ic/__tests__/storage_localnet.test.ts- Skipped by design
- Requires running local ICP replica (
dfx start)
API Stability and Refactoring Policy
@zerc20/client-sdk is published and used by downstream applications (e.g. zerc20-frontend). As a rule, prefer non-breaking refactors.
- Prefer importing from the package root (
import { ... } from "@zerc20/client-sdk"). Subpath imports are considered internal and may change. - Avoid changing the public surface (notably
src/index.tsexports and re-exports) unless necessary. - Avoid breaking changes to the public surface (notably
src/index.tsexports). - When a rename/restructure is desired, keep the old entrypoint and mark it
@deprecated, and introduce the new API in parallel. - Deprecation guideline:
- Keep deprecated entrypoints for at least one minor release (or a clearly communicated timeframe) before removal.
- Removal (or behavioral changes) should only happen in a major version bump, with a migration note.
- If a breaking change is unavoidable:
- Provide a migration note (old → new) in the release/PR description.
- Bump the version appropriately (SemVer).
- Validate against downstream usage (at minimum run TypeScript typecheck/build in
zerc20-frontend).
Downstream Verification (Recommended)
Before merging changes that touch public exports/types, validate against the downstream app:
# In zerc20-frontend
npm run typecheck
npm run buildDependencies
@dfinity/*- Internet Computer SDKviem- Ethereum library@noble/curves- Cryptographic primitivesposeidon-lite- Poseidon hash functionpako- Compression (gzip)
License
MIT
