Using @kusamashield/shielded-transfers on Paseo AssetHub
This is for paseo testnet, you can also utilize the Kusama Assethub and Polkadot Assethub from this library
Library link:
https://www.npmjs.com/package/@kusamashield/shielded-transfers
A step-by-step guide to depositing and withdrawing shielded PAS tokens.
Prerequisites
- Node.js 18+
- A Paseo AssetHub account with PAS balance (get from faucet)
- Circuit artifacts (included in the npm package)
1. Install
npm install @kusamashield/shielded-transfers ethers
# Or pin a specific version:
npm install @kusamashield/shielded-transfers@0.1.2 ethers
2. Copy Circuit Artifacts
The WASM and proving key files need to be served. For a Node.js script, copy them to your project:
cp node_modules/@kusamashield/shielded-transfers/dist/withdraw_phase2_fixed.wasm ./
cp node_modules/@kusamashield/shielded-transfers/dist/withdraw_phase2_fixed_0001.zkey ./
For browser apps, these go in
public/so the library can fetch them viafetch().
3. Connect
import { ethers } from "ethers";
import { PASEO_CONFIG } from "@kusamashield/shielded-transfers";
const provider = new ethers.JsonRpcProvider(PASEO_CONFIG.rpcUrl);
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
4. Deposit PAS
import { shieldedPaseo } from "@kusamashield/shielded-transfers";
const deposit = await shieldedPaseo(
ethers.ZeroAddress, // token address (ZeroAddress = native PAS)
"10", // amount in human-readable format
wallet, // ethers signer
PASEO_CONFIG // chain config (optional, defaults to Paseo)
);
console.log("TX:", deposit.hash);
console.log("Secret:", deposit.secret); // save this for withdrawal
console.log("Nullifier:", deposit.nullifier); // save this for withdrawal
What happens:
- Generates random
secretandnullifier - Computes
commitment = poseidon2([poseidon2([amount, 0]), poseidon2([nullifier, secret])]) - Computes
nullifierHash = poseidon1([nullifier]) - Calls
depositNative(commitment, nullifierHash)on the shield contract, sendingamountPAS - Contract stores
deposits[nullifierHash] = {amount, asset, isSpent: false}and inserts commitment into the Merkle tree
5. Wait for Confirmation
// Wait ~2 blocks for the deposit to be confirmed
await new Promise(r => setTimeout(r, 10000));
6. Withdraw PAS
import { withdrawPaseo } from "@kusamashield/shielded-transfers";
const result = await withdrawPaseo(
ethers.ZeroAddress, // token address
"10", // amount to withdraw
wallet.address, // recipient address
deposit.secret, // from step 4
deposit.nullifier, // from step 4
0, // leaf index (0 for local single-leaf tree)
wallet,
PASEO_CONFIG
);
console.log("TX:", result.hash);
What happens internally:
- Computes the same commitment from
(amount, asset, nullifier, secret) - Builds a local single-leaf LeanIMT with just that commitment
- Generates new random
secretandnullifierfor any change commitment - Calls
snarkjs.groth16.fullProve()with the v4 withdraw circuit - Formats the proof for Solidity and calls
withdrawNative()on the shield contract - Contract verifies the Groth16 proof, checks
deposits[nullifierHash]is unspent and has sufficient funds, transfers PAS to recipient
Complete Example
import { ethers } from "ethers";
import {
PASEO_CONFIG,
shieldedPaseo,
withdrawPaseo,
} from "@kusamashield/shielded-transfers";
async function main() {
const provider = new ethers.JsonRpcProvider(PASEO_CONFIG.rpcUrl);
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
console.log("Balance:", ethers.formatEther(await provider.getBalance(wallet.address)), "PAS");
// Deposit
const deposit = await shieldedPaseo(ethers.ZeroAddress, "10", wallet);
console.log("Deposit TX:", deposit.hash);
// Wait
await new Promise(r => setTimeout(r, 10000));
// Withdraw
const result = await withdrawPaseo(
ethers.ZeroAddress, "10", wallet.address,
deposit.secret, deposit.nullifier, 0, wallet
);
console.log("Withdraw TX:", result.hash);
}
main().catch(console.error);
Pallet Assets (e.g. USDC, PSILV)
For non-native tokens, use the asset variants:
import {
shieldAssetPaseo,
withdrawAssetPaseo,
getPalletAssetPrecompile,
} from "@kusamashield/shielded-transfers";
// Deposit 50 PSILV (asset ID 50000867)
const deposit = await shieldAssetPaseo(50000867, "50", wallet);
// Wait
await new Promise(r => setTimeout(r, 10000));
// Withdraw
const result = await withdrawAssetPaseo(
50000867, "50", wallet.address,
deposit.secret, deposit.nullifier, 0, wallet
);
How It Works (Why No On-Chain Tree Sync?)
The Merkle root in the withdrawal circuit is a private input — it is never checked on-chain.
| Component | Checked On-Chain? | Purpose |
|---|---|---|
nullifierHash | ✅ Yes | Prevents double-spending |
| Groth16 proof | ✅ Yes | Proves knowledge of valid (nullifier, secret, value) |
This is why we build a local single-leaf tree — the root matches the proof, but doesn't need to match the on-chain tree. Security comes entirely from nullifierHash verification.
Gas Estimates
| Operation | Gas | Notes |
|---|---|---|
| Deposit (native) | ~80k | Simple storage write |
| Withdraw (native) | ~300k | Groth16 verification + transfer |
| Deposit (pallet asset) | ~120k | Includes ERC20 approval + transfer |
| Withdraw (pallet asset) | ~350k | Asset transfer from contract |
Troubleshooting
siblings array must have exactly 128 elements— EnsuretreeDepth: 128in config and the circuit matchesWithdraw(128).deposit[nullifierHash] not found— The nullifier from step 4 doesn't match what's on-chain. Ensure you used the same secret/nullifier pair.deposit already spent— The nullifier has already been used in a prior withdrawal. Each deposit can only be withdrawn once.insufficient balance— Fund your account via the Paseo faucet.- Proof generation takes ~17s — Normal for Groth16 with the
Withdraw(128)circuit on a single thread. Browser with SharedArrayBuffer is faster (~5-10s).
Available Functions
| Function | Purpose |
|---|---|
shieldedPaseo(asset, amount, signer, config?) | Deposit native PAS |
shieldedPolkadot(asset, amount, signer, config?) | Deposit native DOT |
shieldedKusama(asset, amount, signer, config?) | Deposit native KSM |
withdrawPaseo(asset, amount, recipient, secret, nullifier, leafIndex, signer, config?) | Withdraw native PAS |
withdrawPolkadot(asset, amount, recipient, secret, nullifier, leafIndex, signer, config?) | Withdraw native DOT |
withdrawKusama(asset, amount, recipient, secret, nullifier, leafIndex, signer, config?) | Withdraw native KSM |
shieldAssetPaseo(assetId, amount, signer, config?) | Deposit pallet asset on Paseo |
shieldAssetPolkadot(assetId, amount, signer, config?) | Deposit pallet asset on Polkadot |
shieldAssetKusama(assetId, amount, signer, config?) | Deposit pallet asset on Kusama |
withdrawAssetPaseo(assetId, amount, recipient, secret, nullifier, leafIndex, signer, config?) | Withdraw pallet asset on Paseo |
withdrawAssetPolkadot(assetId, amount, recipient, secret, nullifier, leafIndex, signer, config?) | Withdraw pallet asset on Polkadot |
withdrawAssetKusama(assetId, amount, recipient, secret, nullifier, leafIndex, signer, config?) | Withdraw pallet asset on Kusama |
Reference
- Library: codeberg.org/KusamaShield/shielded-transfers
- npm:
@kusamashield/shielded-transfers - Paseo RPC:
https://kusama-rpc.laissez-faire.trade/ - Shield contract:
0x3099889C1538f0200B831181cbfb532a4e9A418F - Paseo faucet: faucet.polkadot.io