zerc20-client-sdk
v0.2.1
Published
Client SDK for interacting with ZERC20 rollup services from browser or node environments.
Downloads
462
Readme
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)
│ ├── 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
});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.
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 typecheckTests
npm testCurrent (as of 2026-03-05): 943 passed, 2 skipped
Note: This number will drift over time; treat npm test output as the source of truth.
Skipped 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
