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 snarkjsrapidsnark— faster native binary; user supplies the path (see "Proof generation" below).
- Circuit files - Located at
/home/pi/zk/shielded-transfers/public/(override via theSHIELDED_CIRCUIT_DIRenv var or thecircuit_dirconstructor arg):withdraw_phase2_fixed_v7.wasmwithdraw_phase2_fixed_v7_0001.zkey
- substrateinterface (optional, only for
ReviveShieldedClient) — requires the modern API (Keypair.create_from_uri,SubstrateInterface.compose_call). ⚠️ PyPI'ssubstrateinterface 1.0.0is too old; use the newer git checkout (github.com/polkascan/py-substrate-interface). Imported lazily — the ETH-onlyShieldedClientworks without it, andReviveShieldedClientraises 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
valueis in native plancks, not wei (1 DOT = 1e10, Paseo1 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.callEVM logs are not indexed byeth_getLogson 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 addressclient.chain_id- Chain IDclient.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 weiasset_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 fromdeposit()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) addressclient.h160- Derived EVM H160 addressclient.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
-
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)
-
Merkle Tree: LeanIMT with 128 levels, matching the Solidity contract
-
ZK Proof: Generated with
snarkjs(default) orrapidsnark(optional, faster) using the v7 circuit — see "Proof generation" above.
Known Issues
-
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.
-
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shielded_transfers-0.2.3.tar.gz.
File metadata
- Download URL: shielded_transfers-0.2.3.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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e768e3637499cccf732078bd0ada2e9c2c81f0f23e763fe839861c2fd9a18e03
|
|
| MD5 |
9d5a8252952f07760feae050a0507104
|
|
| BLAKE2b-256 |
df35645790753a39b0d6946fe21d5cd7c3370bf8a9e63325faf7b27a5f9c3bf9
|
File details
Details for the file shielded_transfers-0.2.3-py3-none-any.whl.
File metadata
- Download URL: shielded_transfers-0.2.3-py3-none-any.whl
- Upload date:
- Size: 29.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5e612156410c634020bb29d32e71efefab0de9f810f62803a384ec3f14950878
|
|
| MD5 |
f0fb5d764cecc8a88bf6b866dc77e996
|
|
| BLAKE2b-256 |
63a35b56e497d2c6a1a9420866a9a2d1c9a22587d7adc648fd92c06bb314a198
|