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

Metadata

Release files for shielded-transfers 0.2.8

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for shielded-transfers 0.2.8
File Size Uploaded
shielded_transfers-0.2.8.tar.gz 32.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for shielded-transfers 0.2.8
File Interpreter ABI Platform
shielded_transfers-0.2.8-py3-none-any.whl Python 3 none any Details

Total release size: 64.1 kB

Release files / shielded_transfers-0.2.8.tar.gz

Download URL shielded_transfers-0.2.8.tar.gz
Size 32.9 kB
Tags Source
SHA-256 checksum
How to use checksums
c9c1659028853b053f93badcf631afa107ed4f230d114d8c5066ac709b2f0bbd
BLAKE2b-256 checksum
How to use checksums
0e42eed6441f4e03728aaed1f7ef27a2b7d877d0762c04d6b28d8dcc85d0c485
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.5

Release files / shielded_transfers-0.2.8-py3-none-any.whl

Download URL shielded_transfers-0.2.8-py3-none-any.whl
Size 31.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8b435261715679b13ac390dd0503e782231a417aea77a94e9580f8faedb8a20c
BLAKE2b-256 checksum
How to use checksums
98964ff9569f39001d5b2151d4d8fc0b3037a833fd35e690d292560c8ddab0a5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.5

Release history Release notifications | RSS feed

0.2.16

2 release files

0.2.15

2 release files

0.2.14

2 release files

0.2.13

2 release files

0.2.11

2 release files

0.2.10

2 release files

0.2.9

2 release files

This release

0.2.8 This release

2 release files

0.2.7

2 release files

0.2.6

2 release files

0.2.5

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page