Skip to main content

Shielded Transfers Python SDK

A Python library for interacting with the Kusama Shield v7 privacy pool on Polkadot AssetHub (Paseo).

Overview

This SDK enables shielded (privacy-preserving) deposits and withdrawals using zero-knowledge proofs. It implements the same cryptographic primitives and Merkle tree logic as the Solidity contracts.

Installation

cd /home/pi/zk/shielded-transfers-python
pip install -e .

Or install dependencies only:

pip install web3>=6.0.0 eth-account>=0.9.0 eth-abi>=4.0.0 eth-utils>=2.0.0 requests

Requirements

  • light-poseidon-python - Pure-Python/Rust Poseidon hashing (installed as a dependency). No Node.js needed for hashing.
  • Proof generation — one of:
    • snarkjs (default) — Node CLI, npm install -g snarkjs
    • rapidsnark — faster native binary; user supplies the path (see "Proof generation" below).
  • Circuit files - Located at /home/pi/zk/shielded-transfers/public/ (override via the SHIELDED_CIRCUIT_DIR env var or the circuit_dir constructor arg):
    • withdraw_phase2_fixed_v7.wasm
    • withdraw_phase2_fixed_v7_0001.zkey
  • substrateinterface (optional, only for ReviveShieldedClient) — requires the modern API (Keypair.create_from_uri, SubstrateInterface.compose_call). ⚠️ PyPI's substrateinterface 1.0.0 is too old; use the newer git checkout (github.com/polkascan/py-substrate-interface). Imported lazily — the ETH-only ShieldedClient works without it, and ReviveShieldedClient raises a clear error if a compatible version isn't installed.

Poseidon hashing

All hashing uses light-poseidon-python (a Rust binding), so there is no Node.js dependency for commitments, Merkle tree building, or nullifiers. The hashes match the on-chain hasher / ZK circuit (circomlibjs-compatible constants).

Proof generation

Set the engine at construction time (or via the PROOF_ENGINE env var):

# snarkjs (default) — needs `snarkjs` Node CLI on PATH
client = ShieldedClient(..., proof_engine="snarkjs")

# rapidsnark — faster; you provide the binary path
client = ShieldedClient(..., proof_engine="rapidsnark",
                        rapidsnark_prover="/usr/local/bin/prover")

rapidsnark_prover can also be set via the RAPIDSNARK_PROVER env var. If the binary is missing or the engine is invalid, a clear error is raised.

Two transaction styles

The SDK can submit shielded deposits/withdrawals two ways:

Style Class Account type Tx mechanism
ETH ShieldedClient ECDSA (0x... privkey) eth_sendRawTransaction
Polkadot / Substrate ReviveShieldedClient sr25519 (seed / mnemonic) revive.call extrinsic

Both share the same EVM-based Merkle tree building (build_tree / eth_getLogs, or fetch from the Kusama Shield Flask proxy via /tree-leaves). Only the transaction submission differs.

Quick Start

Polkadot AssetHub (Mainnet)

from shielded_transfers import ShieldedClient, POLKADOT_ASSET_HUB
import json

client = ShieldedClient(
    rpc_url=POLKADOT_ASSET_HUB["rpc"],
    pool_address=POLKADOT_ASSET_HUB["pool"],
    private_key="0x_your_private_key",
    deployment_block=POLKADOT_ASSET_HUB["deployment_block"],
    native_token="DOT",
)

# Check balances
wallet_bal, _ = client.get_balance()
pool_bal, _ = client.get_pool_balance()
print(f"Wallet: {wallet_bal} wei, Pool: {pool_bal} wei")

# Deposit 1 DOT
note = client.deposit(1 * 10**18)

with open("deposit_note.json", "w") as f:
    json.dump(note, f)

# ... later ...

with open("deposit_note.json") as f:
    note = json.load(f)

tx_hash = client.withdraw(note)
print(f"Withdraw TX: {tx_hash}")

Paseo AssetHub (Testnet)

from shielded_transfers import ShieldedClient, PASEO_ASSET_HUB
import json

client = ShieldedClient(
    rpc_url=PASEO_ASSET_HUB["rpc"],
    pool_address=PASEO_ASSET_HUB["pool"],
    private_key="0x_your_private_key",
    deployment_block=PASEO_ASSET_HUB["deployment_block"],
    native_token="DOT",
)

# Deposit 10 PAS (testnet)
note = client.deposit(10 * 10**18)

with open("deposit_note.json", "w") as f:
    json.dump(note, f)

# Withdraw
with open("deposit_note.json") as f:
    note = json.load(f)

tx_hash = client.withdraw(note, recipient="0x_recipient_address")
print(f"Withdraw TX: {tx_hash}")

Polkadot / Substrate (revive.call) — ReviveShieldedClient

For sr25519 (Substrate) accounts — e.g. a polkadot.js browser wallet or Nova account that cannot sign Ethereum transactions directly.

from shielded_transfers import ReviveShieldedClient, POLKADOT_ASSET_HUB

client = ReviveShieldedClient(
    rpc_url=POLKADOT_ASSET_HUB["rpc"],           # EVM JSON-RPC (reads/tree)
    ws_url="wss://asset-hub-polkadot-rpc.n.dwellir.com",   # Substrate WS (revive)
    pool_address=POLKADOT_ASSET_HUB["pool"],
    substrate_uri="0x_your_sr25519_seed",        # or a mnemonic / "//Alice"
    deployment_block=POLKADOT_ASSET_HUB["deployment_block"],
    native_token="DOT",
    native_decimals=10,                          # DOT = 10, Paseo PAS = 12
)

# One-time mapping of the sr25519 account to its H160 (only needed once)
client.ensure_mapped()

# Deposit 0.01 DOT via revive.call
note = client.deposit_revive(amount_dot=0.01)
print("Deposit TX:", note["tx_hash"])
print("Secret:   ", note["secret"])              # keep private for withdrawal

# Withdraw back via revive.call, fetching the current tree from the Flask proxy
tx_hash = client.withdraw_revive(
    note,
    recipient=client.h160,
    proxy_base_url="https://proxyswap.laissez-faire.trade",
)
print("Withdraw TX:", tx_hash)

Important notes for revive.call:

  • The value is in native plancks, not wei (1 DOT = 1e10, Paseo 1 PAS = 1e12).
  • Requires a large weight limit (handled internally) and a non-zero storage deposit (~0.1 native units) — the SDK sets these automatically.
  • revive.call EVM logs are not indexed by eth_getLogs on AssetHub, so the tree is best fetched from the Kusama Shield Flask proxy (proxy_base_url=...GET /tree-leaves/<network>).

Configuration

Constructor Parameters

Parameter Type Required Description
rpc_url str Yes RPC endpoint URL
pool_address str Yes Shielded pool contract address
private_key str Yes Account private key
deployment_block int Yes Block number when pool was deployed
circuit_dir Path No Directory containing circuit files
native_token str No Native token name (e.g. "DOT")
proof_engine str No "snarkjs" (default) or "rapidsnark"
rapidsnark_prover str No Path to rapidsnark prover binary (for proof_engine="rapidsnark")

Active Deployments

# Polkadot AssetHub (Mainnet)
POLKADOT_ASSET_HUB = {
    "rpc": "https://polkadot-assethub-rpc.laissez-faire.trade",
    "pool": "0x0D694Da746e73D1e255c1894F90e38170db45809",
    "verifier": "0x6A13781E43AEA21918120CD0E7a2ed8614c01e14",
    "poseidon": "0xB8F0C6679D6Cc56450470522Bd96573C3D615052",
    "deployment_block": 18697500,
    "chain_id": 420420419,
}

# Paseo AssetHub (Testnet)
PASEO_ASSET_HUB = {
    "rpc": "https://paseo-assethub-rpc.laissez-faire.trade",
    "pool": "0xbcE09D4De052b2816df1285663ac89528DF45380",
    "verifier": "0xcA4cBc5d31eccd08d393C43aF492F729FF30b685",
    "poseidon": "0x1d165f6fE5A30422E0E2140e91C8A9B800380637",
    "deployment_block": 11273491,
    "chain_id": 420420421,
}

API Reference

ShieldedClient

client = ShieldedClient(rpc_url, pool_address, private_key, deployment_block)

Properties

  • client.address - Account address
  • client.chain_id - Chain ID
  • client.pool_address - Pool contract address

Methods

get_balance()
wei, formatted = client.get_balance()

Returns wallet balance in wei and formatted string.

get_pool_balance()
wei, formatted = client.get_pool_balance()

Returns pool balance in wei and formatted string.

get_tree_size()
size = client.get_tree_size()

Returns the current Merkle tree size from the contract.

get_root()
root = client.get_root()

Returns the current Merkle tree root.

is_known_root(root)
known = client.is_known_root(root)

Checks if a root is in the 16-slot known-roots window.

deposit(amount_wei, asset_id=0)
note = client.deposit(amount_wei, asset_id=0)

Creates a shielded deposit.

Parameters:

  • amount_wei (int): Amount in wei
  • asset_id (int): Asset ID (0 for native PAS)

Returns:

{
    "secret": "0x...",           # Secret key (keep private!)
    "nullifier": 123...,         # Nullifier for proving
    "nullifier_hash": 456...,    # Hash for double-spend prevention
    "commitment": 789...,        # Public commitment
    "amount_wei": 10000000000000000000,
    "asset_id": 0,
    "tx_hash": "0x...",
    "block_number": 11000000,
    "deposit_block": 11085793,
}
build_tree(start_block=None)
tree = client.build_tree(start_block=11085793)

Builds the Merkle tree from on-chain events.

Returns: LeanIMT instance

withdraw(note, recipient=None)
tx_hash = client.withdraw(note, recipient=None)

Parameters:

  • note (dict): Deposit note from deposit()
  • recipient (str): Recipient address (default: self)

Returns: Transaction hash

ReviveShieldedClient

client = ReviveShieldedClient(rpc_url, ws_url, pool_address, substrate_uri, deployment_block)

Subclasses ShieldedClient and adds Substrate (revive.call) tx submission. Additional constructor parameters:

Parameter Type Required Description
ws_url str Yes Substrate WebSocket RPC URL
substrate_uri str Yes sr25519 seed (0x...), mnemonic, or SURI (//Alice)
native_decimals int No Native token decimals (DOT=10, Paseo=12). Defaults to a heuristic
ss58_format int No SS58 format (default 42 for AssetHub)

Properties

  • client.ss58_address - Substrate (SS58) address
  • client.h160 - Derived EVM H160 address
  • client.keypair - substrateinterface Keypair

Methods

is_mapped()
mapped = client.is_mapped()

Returns whether the sr25519 account is mapped to its H160 for revive.

ensure_mapped()
client.ensure_mapped()  # or tx_hash = client.ensure_mapped()

Maps the account (one-time, via revive.mapAccount) if not already mapped.

deposit_revive(amount_dot, wait_for_inclusion=True)
note = client.deposit_revive(0.01)

Deposits native tokens via revive.call. amount_dot is in native units.

withdraw_revive(note, recipient, wait_for_inclusion=True, proxy_base_url=None)
tx_hash = client.withdraw_revive(note, recipient=client.h160,
                                 proxy_base_url="https://proxyswap.laissez-faire.trade")

Withdraws via revive.call. recipient is an EVM H160. If proxy_base_url is set, the Merkle tree is fetched from the proxy's /tree-leaves/<network>; otherwise it is built locally via eth_getLogs (build_tree).

fetch_tree_from_proxy(base_url="https://proxyswap.laissez-faire.trade", network=None)
tree = client.fetch_tree_from_proxy(network="polkadot")

Fetches the current tree leaves from the Kusama Shield Flask proxy and returns a LeanIMT.

Commitment Generation

from shielded_transfers import generate_commitment

note = generate_commitment(secret_hex, amount_wei, asset_id)

LeanIMT

from shielded_transfers import LeanIMT

tree = LeanIMT()
tree.insert(leaf)
tree.get_proof(leaf_index)
tree.find_leaf_index(leaf)
tree.root
tree.size

CLI Usage

Deposit

shielded-deposit \
  --amount 10 \
  --rpc-url https://paseo-assethub-rpc.laissez-faire.trade \
  --private-key 0x... \
  --output deposit_note.json

Withdraw

shielded-withdraw \
  --note deposit_note.json \
  --rpc-url https://paseo-assethub-rpc.laissez-faire.trade \
  --private-key 0x... \
  --recipient 0x...

Environment Variables

export PASEO_RPC_URL="https://paseo-assethub-rpc.laissez-faire.trade"
export PRIVATE_KEY="0x_your_private_key"

Architecture

shielded_transfers/
├── __init__.py         # Package exports
├── client.py           # ShieldedClient (ETH / eth_sendRawTransaction)
├── revive.py           # ReviveShieldedClient (Substrate / revive.call)
├── commitment.py       # Commitment generation (Poseidon)
├── tree.py             # LeanIMT Merkle tree implementation
├── poseidon_polkadot.py # Poseidon via light-poseidon-python (Rust)
├── constants.py        # Selectors, BN254 parameters
├── exceptions.py       # Custom exceptions
├── networks.py         # Network configs (Polkadot, Paseo)
└── cli.py              # Command-line interface

Key Components

  1. Commitment Generation: Uses light-poseidon-python (Rust binding, no Node.js) to compute:

    • nullifier = poseidon2(secret, 1)
    • nullifier_hash = poseidon1(nullifier)
    • precommitment = poseidon2(nullifier, secret)
    • value_asset_hash = poseidon2(amount, asset_id)
    • commitment = poseidon2(value_asset_hash, precommitment)
  2. Merkle Tree: LeanIMT with 128 levels, matching the Solidity contract

  3. ZK Proof: Generated with snarkjs (default) or rapidsnark (optional, faster) using the v7 circuit — see "Proof generation" above.

Known Issues

  1. Withdraw event scanning: The tree building from events may occasionally miss deposits due to RPC event indexing. The script includes recovery logic to handle this.

  2. Gas estimation: Some RPCs may fail gas estimation. The SDK uses a default of 500k gas as fallback.

Error Handling

from shielded_transfers import (
    ShieldedTransfersError,
    DepositError,
    WithdrawError,
    ProofError,
)

try:
    note = client.deposit(amount)
except DepositError as e:
    print(f"Deposit failed: {e}")
except ProofError as e:
    print(f"ZK proof failed: {e}")

Testing

# Run the original roundtrip script
cd /home/pi/zk/rust_tx_gen
python3 paseo_v7_roundtrip.py --deposit-only
python3 paseo_v7_roundtrip.py --withdraw deposit_note_*.json

# Or use the library
cd /home/pi/zk/shielded-transfers-python
python3 -c "
from shielded_transfers import ShieldedClient, PASEO_ASSET_HUB
client = ShieldedClient(
    rpc_url=PASEO_ASSET_HUB['rpc'],
    pool_address=PASEO_ASSET_HUB['pool'],
    private_key='0x...',
    deployment_block=PASEO_ASSET_HUB['deployment_block'],
)
note = client.deposit(10**18)
print(f'Deposit: {note[\"tx_hash\"]}')
"

Files

File Description
client.py Main SDK client with ETH deposit/withdraw (eth_sendRawTransaction)
revive.py Substrate revive.call deposit/withdraw (ReviveShieldedClient)
commitment.py Commitment and Poseidon hashing
tree.py LeanIMT Merkle tree
constants.py Contract addresses, selectors
exceptions.py Custom exception classes
cli.py Command-line tools

Related

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

shielded_transfers-0.2.2.tar.gz (31.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

shielded_transfers-0.2.2-py3-none-any.whl (29.9 kB view details)

Uploaded Python 3

File details

Details for the file shielded_transfers-0.2.2.tar.gz.

File metadata

  • Download URL: shielded_transfers-0.2.2.tar.gz
  • Upload date:
  • Size: 31.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.5

File hashes

Hashes for shielded_transfers-0.2.2.tar.gz
Algorithm Hash digest
SHA256 f8dbeacc5ab0a6599d348f8ef18f33cbbeb912a19da4114ddd63eae34a6b0a43
MD5 64ebe92dcf1535305c243685332586df
BLAKE2b-256 0bc6cb78f5571b0a9f9b51707d5893734d2d8bf0017d11d7bb25411abe06fbec

See more details on using hashes here.

File details

Details for the file shielded_transfers-0.2.2-py3-none-any.whl.

File metadata

File hashes

Hashes for shielded_transfers-0.2.2-py3-none-any.whl
Algorithm Hash digest
SHA256 d2349c3644d0b016a560399344e32695c0f3d6162405b662ebf5d1243f0f556c
MD5 c51aefee5ba6a944ca603fabb8dcb184
BLAKE2b-256 2118aa00a2b40ea6b88103a622c1c292c09963b644d389e8f03af18a508bab16

See more details on using hashes here.

Release history Release notifications | RSS feed

0.2.5

2 files

0.2.4

2 files

0.2.3

2 files

This release

0.2.2 This release

2 files

0.2.1

2 files

0.2.0

2 files

0.1.1

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page