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
Metadata
Release files for shielded-transfers 0.2.11
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| shielded_transfers-0.2.11.tar.gz | 34.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| shielded_transfers-0.2.11-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 66.4 kB
Release files / shielded_transfers-0.2.11.tar.gz
| Download URL | shielded_transfers-0.2.11.tar.gz |
|---|---|
| Size | 34.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a50dad61a27f99b78dc1af00f2048660d95a62e5fc12616ebb6d1fbaff372d1b
|
|
BLAKE2b-256 checksum How to use checksums |
3c8e5752237ca1a7ad9aad468f8969bd74fdfe00537271675e0c2071838e832c
|
| 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.11-py3-none-any.whl
| Download URL | shielded_transfers-0.2.11-py3-none-any.whl |
|---|---|
| Size | 32.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
73bde3d5af39cd9ef76efcb99ffdfc41465763e7683ab3c676719f1edfe06eb8
|
|
BLAKE2b-256 checksum How to use checksums |
b9f272ed7f385e5a0d9e30d8dd7cd787742ee1152567f92739fca4173b5ab69a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.5
|