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 via fetch().

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:

  1. Generates random secret and nullifier
  2. Computes commitment = poseidon2([poseidon2([amount, 0]), poseidon2([nullifier, secret])])
  3. Computes nullifierHash = poseidon1([nullifier])
  4. Calls depositNative(commitment, nullifierHash) on the shield contract, sending amount PAS
  5. 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:

  1. Computes the same commitment from (amount, asset, nullifier, secret)
  2. Builds a local single-leaf LeanIMT with just that commitment
  3. Generates new random secret and nullifier for any change commitment
  4. Calls snarkjs.groth16.fullProve() with the v4 withdraw circuit
  5. Formats the proof for Solidity and calls withdrawNative() on the shield contract
  6. 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.

ComponentChecked On-Chain?Purpose
nullifierHash✅ YesPrevents double-spending
Groth16 proof✅ YesProves 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

OperationGasNotes
Deposit (native)~80kSimple storage write
Withdraw (native)~300kGroth16 verification + transfer
Deposit (pallet asset)~120kIncludes ERC20 approval + transfer
Withdraw (pallet asset)~350kAsset transfer from contract

Troubleshooting

  • siblings array must have exactly 128 elements — Ensure treeDepth: 128 in config and the circuit matches Withdraw(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

FunctionPurpose
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