xian-zk
xian-zk is the current external proving, wallet, and deployment-tooling package for Xian's shielded note and shielded command flows.
Validators that enable zk verification need the native xian-zk verifier bindings available in the node image because the runtime zk bridge calls into those bindings. They do not need wallet/prover tooling or proving keys in the consensus path. xian-zk is also the off-chain package for:
- proving shielded deposit, transfer, and withdraw transitions
- proving shielded command execution transitions
- scanning encrypted note records
- managing shielded wallet state
- generating deployment bundles and verifying-key manifests
Installation
uv add xian-tech-zkThe published PyPI package name is xian-tech-zk. The import package remains xian_zk, and the CLI command remains xian-zk-shielded-bundle.
For local development against the editable workspace package:
cd xian-contracting/packages/xian-zk
uv sync --group dev
uv run maturin develop -rWhat It Ships
The current package surface includes:
- Groth16 / BN254 verification helpers
- shielded-note proof generation for the
shielded_note_v3circuit family - shielded-command proof generation for the
shielded_command_v4circuit family - note commitments, nullifiers, Merkle roots, and auth-path helpers
ShieldedWalletfor seed backup, wallet snapshots, sync, note selection, and request planningShieldedRelayTransferWalletfor relayed note-to-note transfers where the public L1 sender is the relayer instead of the hidden note ownerShieldedRelayTransferProverfor proof-bound relayer-fee transfers on top of the shielded note poolShieldedCommandWalletfor planning relayed shielded commands with proof-bound relayer fees and optional proof-bound public spend- encrypted note payload handling with separated spend keys and viewing keys
- anonymous note-discovery tags for newer encrypted payloads
- proof-bound output payload hashes for note and command outputs
- optional disclosed viewers on output payloads
- a CLI for generating a random shielded-note trusted-setup bundle plus a registry-ready manifest
- programmatic bundle / manifest generation for shielded-command circuits
- a trusted local prover service and matching prover clients for wallet-side proving offload
Canonical Wallet Model
The current Python-side wallet model is:
owner_secret: spend authority inside the shielded poolviewing_private_key/viewing_public_key: note decryption and disclosure authorityShieldedWallet: local state wrapper around keys, note records, commitments, and transaction planning
Basic example:
from xian_py import Xian
from xian_zk import ShieldedWallet
indexed_client = Xian("http://127.0.0.1:26657")
wallet = ShieldedWallet.from_parts(
asset_id=token.asset_id(signer="sys"),
owner_secret="0x...",
viewing_private_key="11" * 32,
)
wallet.sync_transactions(
indexed_client.list_txs_by_contract("con_private_usd", limit=64, offset=0)
)
balance = wallet.available_balance()ShieldedWallet now syncs from indexed transaction history rather than reading encrypted note payloads out of contract state. On a live node, that means BDS or another equivalent indexed transaction feed needs to be available.
For newer payloads, xian-zk no longer embeds the recipient viewing public key in cleartext. Instead it stores anonymous discovery-tag entries plus ephemeral keys inside the encrypted payload bundle. Wallet sync uses those entries to prefilter candidate notes before full decryption.
Wallet snapshots:
seed_backup = wallet.export_seed_json()
state_snapshot = wallet.to_json()
restored = ShieldedWallet.from_json(state_snapshot)seed_backup is the minimal recovery secret. state_snapshot is the richer resume file that keeps synced commitments and note state.
If you already have extracted note records, you can prefilter them before a full sync:
candidates = wallet.candidate_records(records)
sync_result = wallet.sync_records(candidates)Indexed note records now also expose payload_tags, which indexers can persist for future selective note-discovery queries. Newer wallet sync flows should use the higher-level shielded_wallet_history feed first. That feed keeps the full commitment sequence in note-index order and only includes output_payload for rows whose indexed tag matches the wallet. If that feed is not available, ShieldedWallet.sync_indexed_client(...) still falls back to the older event/tag/transaction fan-out path.
Browser and mobile wallet integrations can now treat state_snapshot as a first-class user backup. Stored shielded snapshots are included in full wallet backup exports, and users can also store, export, or remove shielded wallet snapshots directly through the wallet settings flows. Both wallet apps can now also check indexed shielded history after a stored snapshot so users can tell whether the chain has already advanced beyond that snapshot before attempting a restore or spend.
Runtime Cost Direction
Recent shielded-fee work moved the Merkle frontier append and relay-digest hot paths out of Python contract code and into native xian-zk bindings exposed through the runtime zk bridge. That was the change that materially lowered shielded transaction cost.
Using the reproducible benchmark in xian-abci/scripts/benchmark_shielded_chi.py, the local April 2026 numbers for the current implementation are roughly:
- shielded deposit with 2 outputs:
3,347chi - shielded transfer with 2 inputs / 2 outputs:
3,600chi - shielded withdraw with 1 input / 1 output:
3,128chi - exact withdraw with no new output note:
2,175chi - relayed hidden-sender transfer:
5,288chi
Those are still above a plain public transfer, but they are dramatically lower than the earlier five-digit shielded costs from the all-Python contract path.
Planning Shielded Actions
ShieldedWallet can build note requests and payloads directly:
transfer_plan = wallet.build_transfer(
recipient=recipient_bundle.recipient,
amount=25,
)
withdraw_plan = wallet.build_withdraw(
amount=10,
recipient="alice",
)The returned plan objects include:
- the proving request
- the selected input notes
- any change note
- encrypted output payloads to pass into the contract
Exact exits are supported. If a withdraw needs no change note, the plan uses outputs=[].
For hidden-sender note-to-note transfers, use ShieldedRelayTransferWallet:
from xian_zk import (
ShieldedRelayTransferProver,
ShieldedRelayTransferWallet,
)
relay_wallet = ShieldedRelayTransferWallet.from_json(wallet.to_json())
relay_plan = relay_wallet.build_relay_transfer(
recipient=recipient_bundle.recipient,
amount=25,
relayer="relayer-1",
chain_id="xian-mainnet-1",
fee=2,
)
relay_prover = ShieldedRelayTransferProver.build_random_bundle(
contract_name="con_private_usd",
vk_id_prefix="private-usd-relay-20260406",
)
relay_proof = relay_prover.prove_relay_transfer(relay_plan.request)The relayed transfer binds all of the following into the proof statement:
- the spent input nullifiers
- the relayer account
- the chain id
- the optional expiry
- the exact relayer fee
That means the relayer can submit the transaction and get paid, but cannot change the fee, redirect the execution to a different relayer account, or reuse the proof on another chain.
Private Submission Relayer
The proof-bound relayed transfer and shielded-command paths now also have a concrete network-facing relayer service:
- Python client:
ShieldedRelayerClient - async Python client:
ShieldedRelayerAsyncClient - Python pool clients:
ShieldedRelayerPoolClient,ShieldedRelayerAsyncPoolClient - TypeScript client:
XianShieldedRelayerClient - TypeScript pool client:
XianShieldedRelayerPoolClient - stack-managed runtime:
xian-shielded-relayer
HTTP surface:
GET /healthGET /v1/infoGET /metricsPOST /v1/quotePOST /v1/jobs/shielded-note-transferPOST /v1/jobs/shielded-commandGET /v1/jobs/{job_id}
The important rule is that quote returns the exact relayer account, chain id, fee, and expiry that the proof must bind. The relayer service then submits the matching on-chain call through the normal xian-py transaction path.
Networks advertise one or more relayers in their manifest with the shielded_relayers field.
The canonical network manifests can also carry three privacy-specific policy surfaces alongside those relayer endpoints:
privacy_artifact_catalog: a checksum-pinned catalog of approved shielded registry manifests for that networkshielded_history_policy: the network's compatibility and retention commitment forshielded_wallet_historyprivacy_submission_policy: the network's operator-facing relayer auth and hidden-sender submission posture
Example:
{
"shielded_relayers": [
{
"id": "primary-eu",
"base_url": "https://relayer-eu.example.org",
"auth_scheme": "bearer",
"public_info": true,
"public_quote": false,
"public_job_lookup": false,
"priority": 10,
"submission_kinds": [
"shielded_note_relay_transfer",
"shielded_command"
]
}
]
}The current CLI-side selection rule is intentionally simple: sort by priority, then id, then base_url, and expose the first entry as the primary relayer while keeping the full catalog available to tooling. The routed pool clients in xian-py and xian-js now use that same ordered catalog.
If a public network actually enables shielded assets, the recommended posture is:
- publish approved registry manifests through
privacy_artifact_catalog - treat
shielded_history_policyas a real operator commitment, not a hint - make the relayer auth and retention posture in
privacy_submission_policymatch the deployed relayer configuration
Current pool-client behavior is:
get_info/getInfoandget_quote/getQuotecan fail over across the ordered relayer list- submit and
get_job/getJobstay explicitly routed when more than one relayer is configured - if only one candidate relayer exists for a submission kind, submit can use it directly without an explicit relayer id
That split is intentional: quote/info are safe to retry, but submissions are proof-bound to a specific relayer and job ids are relayer-local.
For local operator use through xian-stack:
export XIAN_SHIELDED_RELAYER_PRIVATE_KEY=<relayer-ed25519-private-key>
python3 ./scripts/backend.py start --no-service-node --shielded-relayer
python3 ./scripts/backend.py endpoints --no-service-node --shielded-relayerThe relayer can optionally require a bearer token for quote and submission routes:
export XIAN_SHIELDED_RELAYER_AUTH_TOKEN=local-dev-tokenIf the relayer binds to a non-loopback host, the current stack-managed runtime now requires XIAN_SHIELDED_RELAYER_AUTH_TOKEN. Treat that as a minimum hardening rule, not an optional production extra.
Operational knobs now also include:
export XIAN_SHIELDED_RELAYER_PUBLIC_INFO=1
export XIAN_SHIELDED_RELAYER_PUBLIC_QUOTE=0
export XIAN_SHIELDED_RELAYER_PUBLIC_JOB_LOOKUP=0
export XIAN_SHIELDED_RELAYER_METRICS_ENABLED=1
export XIAN_SHIELDED_RELAYER_METRICS_PUBLIC=0
export XIAN_SHIELDED_RELAYER_RATE_LIMIT_REQUESTS_PER_MINUTE=120
export XIAN_SHIELDED_RELAYER_RATE_LIMIT_BURST=30
export XIAN_SHIELDED_RELAYER_JOB_HISTORY_TTL_SECONDS=86400expires_at uses the canonical contract-time string format:
YYYY-MM-DD HH:MM:SSThat second-resolution format is important because the proof binds the same logical expiry value the contract checks on-chain.
Operational privacy notes:
- the relayer is a trusted submission hop, not an anonymity network
- the relayer can still observe submitter transport metadata unless another anonymity layer sits in front of it
- relayer job lookups are for short-lived operational status, not long-term privacy-preserving storage
/metricsis for operator monitoring, and should normally stay private on shared relayers
Trusted Local Prover Service
xian-zk now ships a local prover-service entrypoint for wallet-side proving offload:
uv run xian-zk-prover-service \
--host 127.0.0.1 \
--port 8787 \
--auth-token local-dev-token \
--insecure-dev-note \
--insecure-dev-commandClients can talk to that service directly:
from xian_zk import ShieldedNoteProverClient
client = ShieldedNoteProverClient(
"http://127.0.0.1:8787",
auth_token="local-dev-token",
)
proof = client.prove_deposit(deposit_plan.request)This is a trusted local companion service, not a true split-prover protocol. It improves browser/mobile deployment options, but the service still handles the witness material.
For relayed anonymous execution, use ShieldedCommandWallet:
from xian_zk import ShieldedCommandWallet
command_wallet = ShieldedCommandWallet.from_json(wallet.to_json())
command_plan = command_wallet.build_command(
target_contract="con_shielded_dex_adapter",
relayer="relayer-1",
chain_id="xian-mainnet-1",
fee=5,
public_amount=100,
payload={
"action": "swap_exact_in",
"pair": 7,
"recipient": "alice",
"amount_out_min": 95,
"deadline": "2026-04-04 13:00:00",
},
)The command plan includes:
- the proving request
- selected hidden input notes
- any hidden change note
- encrypted hidden output payloads
- the bound
command_bindingandexecution_tag
The public_amount is part of the proof statement. Allowlisted adapter contracts can spend only that exact public budget during the active execution.
Deployment CLI
For a real deployment, use the CLI instead of the deterministic dev bundle:
uv run xian-zk-shielded-bundle generate-note \
--output-dir ./artifacts/private-usd-mainnet \
--contract-name con_private_usd \
--vk-id-prefix private-usd-mainnet-20260327The output directory contains:
shielded-note-bundle.json: private proving bundleshielded-note-registry-manifest.json: public verifying-key manifestshielded-note-deployment.md: operator registration / binding guide
The manifest can be registered directly in zk_registry and then bound into the token contract with configure_vk(...).
The registry entries now carry richer metadata. The easiest operator path is to register each manifest entry directly after removing the helper-only action field:
for entry in manifest["registry_entries"]:
args = dict(entry)
args.pop("action", None)
zk_registry.register_vk(**args, signer="sys")Dev Bundle vs Deployment Bundle
For local tests:
from xian_zk import ShieldedNoteProver
prover = ShieldedNoteProver.build_insecure_dev_bundle()For deployment:
prover = ShieldedNoteProver.build_random_bundle(
contract_name="con_private_usd",
vk_id_prefix="private-usd-mainnet-20260327",
)
manifest = prover.registry_manifest()For shielded commands:
from xian_zk import ShieldedCommandProver
command_prover = ShieldedCommandProver.build_random_bundle(
contract_name="con_shielded_commands",
vk_id_prefix="shielded-command-mainnet-20260404",
)
command_manifest = command_prover.registry_manifest()The deployment bundle is random and suitable for operator use. The dev bundle is deterministic local tooling only.
For relayed note transfers:
from xian_zk import ShieldedRelayTransferProver
relay_prover = ShieldedRelayTransferProver.build_random_bundle(
contract_name="con_private_usd",
vk_id_prefix="private-usd-relay-20260406",
)
relay_manifest = relay_prover.registry_manifest()Important Warning
The built-in deployment generator is still a single-party random trusted setup. That is a real deployment path, but it is not a substitute for an MPC ceremony.
If a network wants ceremony-grade trust reduction, use the explicit import and validation flow rather than treating the built-in random generator as an MPC flow:
xian-zk-shielded-bundle validate-note --bundle ./ceremony-note-bundle.json
xian-zk-shielded-bundle import-note \
--bundle ./ceremony-note-bundle.json \
--output-dir ./artifacts/note
xian-zk-shielded-bundle validate-command --bundle ./ceremony-command-bundle.json
xian-zk-shielded-bundle import-command \
--bundle ./ceremony-command-bundle.json \
--output-dir ./artifacts/commandThe import flow enforces bundle-shape validation plus setup metadata checks. If setup_mode is not insecure-dev or single-party, the bundle must carry a non-empty setup_ceremony label so operators can track ceremony provenance in network rollout and rotation policy.
Prover Service Guardrails
xian-zk-prover-service still assumes the prover is trusted with witness material, but it now has basic bind-safety guardrails:
- loopback binds are allowed without extra flags
- non-loopback binds are refused unless
--unsafe-allow-remote-hostis passed - non-loopback binds also require a non-empty
--auth-token
So the intended posture is still "local trusted prover first", with deliberate operator acknowledgement required before exposing the service to any remote network path.