@zerc20/client-sdk
v0.7.3
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, scanReceivingsForRecipient } 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 scanReceivingsForRecipient({
client,
vetKey,
expectedRecipientAddress: account.address,
// ...
});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);
// fee.relayerFee: bigint
// fee.redeemFeeBps: number, quoted from the destination Verifier
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 message retrieval, signature validation, ECDSA canonicalization, 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.
Before hashing, the signature is canonicalized (low-s normalization and v normalized to 27/28) so the same key always yields the same seed even if a wallet or hardware signer returns a valid-but-non-canonical encoding. Standard wallets (low-s, v ∈ {27,28}) are unaffected — the seed is unchanged.
To also discover funds received before canonicalization, use deriveSeedCandidates. It signs once and returns every seed the key could have produced — the canonical seed plus each pre-canonicalization (malleable-encoding) seed:
import { deriveSeedCandidates } from "@zerc20/client-sdk";
// Scan all candidate seeds during the migration window so existing funds stay visible.
const candidates = await deriveSeedCandidates(signMessage);Deriving the candidates from a single signature (rather than re-signing and hashing raw bytes) keeps old funds discoverable even if the wallet later returns a different equivalent encoding. Receiving going forward should use deriveSeed.
Stability still depends on deterministic (RFC 6979) signing. Canonicalization only collapses signature malleability; a signer that changes its ECDSA nonce produces a different
r— and therefore a different, unrecoverable seed.
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,
prepareRedeemTransactionForRecipient,
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 });
// Redeem fee data is read from the Verifier. `redeemableDiff` is the new
// redeemable delta before redeem / relayer fee deductions.
if (redeemContext.redeemBlockedReason === "amount_too_small") {
throw new Error("Redeem amount is too small after redeem fee");
}
console.log({
redeemFeeBps: redeemContext.redeemFeeBps,
feeRecipient: redeemContext.feeRecipient,
redeemableDiff: redeemContext.redeemableDiff,
redeemFee: redeemContext.redeemFee,
redeemNetValue: redeemContext.redeemNetValue,
});
// 2. Prepare the transaction (SDK picks single vs batch automatically)
const tx = await prepareRedeemTransactionForRecipient({
redeemContext,
burn,
expectedRecipientAddress: account.address,
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,
});indexerFetchLimit is optional and defaults to 20. Receive flows accept values
from 1 to MAX_INDEXER_FETCH_LIMIT (100), matching the maximum number of
leaf proofs fetched in a single indexer proof request.
The returned RedeemTransaction is pure data ({ address, abi, functionName, args, mode }) with no wallet dependency.
Redeem API migration note
Recipient-checked APIs are preferred for new integrations:
- Use
scanReceivingsForRecipientinstead of deprecatedscanReceivings. - Use
prepareRedeemTransactionForRecipientinstead of deprecatedprepareRedeemTransaction. - Use
buildBatchRedeemTransactionForRecipientinstead of deprecatedbuildBatchRedeemTransaction.
The recipient-checked variants require expectedRecipientAddress and reject or skip decoded burn payloads whose generalRecipient.address does not match it. This prevents callers from preparing redeem calldata for an unexpected recipient while preserving the old entrypoints during the migration window.
Local teleport mode
collectRedeemContext and getAnnouncementStatus accept teleportMode: "local" for single-chain local-only redeem flows. Local mode skips Hub aggregation state reads, so hub is optional. Provide per-chain Verifier readers instead:
import { createLocalRootReader } from "@zerc20/client-sdk";
const verifierContractsByChain = new Map([
[token.chainId, createLocalRootReader(readProvider, token.verifierAddress)],
]);
const redeemContext = await collectRedeemContext({
burn,
tokens: [token],
verifierContract,
indexerUrl,
teleportMode: "local",
verifierContractsByChain,
});
const tx = await prepareRedeemTransactionForRecipient({
redeemContext,
burn,
expectedRecipientAddress: account.address,
teleportMode: "local",
teleportProofClient,
decider, // required only when eligible.length > 1
});When teleportMode is omitted or set to "global", hub is still required and the SDK fetches aggregation state from the Hub as before.
collectRedeemContext and getAnnouncementStatus use the Verifier as the
source of truth for redeem fee data:
redeemFeeBpsis read fromredeemFeeBps()redeemFeeis read fromquoteRedeemFee(redeemableDiff)feeRecipientis read fromfeeRecipient()and identifies the wallet authorized to withdraw accrued protocol redeem fees- Accrued protocol redeem fees are tracked by
redeemFeeBalance()and minted only whenfeeRecipientcallswithdrawRedeemFees() redeemableDiffistotalEligibleValue - totalTeleported, clamped to0redeemNetValueisredeemableDiff - redeemFee, clamped to0redeemBlockedReasonis set to"amount_too_small"when the Verifier would reject becauseredeemFee >= redeemableDiff
Redeem-fee view failures are surfaced to callers instead of being treated as a zero-fee redeem. Callers should retry or surface the read failure rather than displaying a stale or fee-free quote.
Relay fee quotes also include redeemFeeBps. submitRelayTeleport sends the
quoted bps to the relay; if the relay reports that the bps changed before
submission, the SDK throws RedeemFeeBpsDriftError so callers can re-quote.
For accounting, prefer receipt-derived values over pre-submit quotes.
submitRedeemTransaction returns redeemFeeAmount / redeemFeeGrossAmount
when the provider's receipt includes logs. For relayed redeems, call
readRedeemFeeAccruedFromReceipt({ readProvider, transactionHash, verifierAddress })
with the returned relay transaction hash to decode the actual
RedeemFeeAccrued event from chain.
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,
buildBatchRedeemTransactionForRecipient,
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 = buildBatchRedeemTransactionForRecipient({
redeemContext,
burn,
expectedRecipientAddress: account.address,
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
