Building Shielded Commands
This tutorial uses con_shielded_commands plus the xian_zk proving toolkit to execute a public on-chain action without linking that action to a normal public sender account.
The core idea is simple:
- hidden notes fund the action
- a proof binds the exact target contract, payload, relayer, expiry, and fee
- an allowlisted adapter contract performs the public side effect
For the package-level toolkit and API surface, see xian-zk.
What You Get
- anonymous relayed execution through
con_shielded_commands - proof-bound relayer fees
- proof-bound hidden change outputs
- proof-bound public spend budgets for adapter contracts
- direct execution indexes by nullifier, command binding, and execution tag
What Is Public
- the target contract being called
- the adapter payload
- the public side effects of the adapter call
- relayer identity
This is sender privacy, not invisible contract execution.
Before You Start
You need:
- a node/runtime with zk verification enabled
- a
zk_registrycontract available on the network - a deployed XSC-0001 token used as the command pool's fee/spend asset
- the
con_shielded_commands.pysource fromxian-contracts - at least one allowlisted adapter such as
con_shielded_dex_adapter.pyorcon_shielded_scheduler_adapter.py - the
xian_zkPython package fromxian-contracting
The DEX example also assumes that con_dex and the referenced pair path are deployed and funded.
Step 1: Deploy The Registry, Command Pool, And Adapter
from pathlib import Path
from contracting.local import ContractingClient
client = ContractingClient()
client.flush()
zk_registry_source = Path(
"xian-configs/contracts/zk_registry.s.py"
).read_text()
currency_source = Path(
"xian-configs/contracts/currency.s.py"
).read_text()
shielded_commands_source = Path(
"xian-contracts/contracts/shielded-commands/src/con_shielded_commands.py"
).read_text()
shielded_dex_adapter_source = Path(
"xian-contracts/contracts/shielded-dex-adapter/src/con_shielded_dex_adapter.py"
).read_text()
client.submit(
zk_registry_source,
name="zk_registry",
constructor_args={"owner": "sys"},
signer="sys",
)
zk_registry = client.get_contract_proxy("zk_registry")
client.submit(
currency_source,
name="currency",
constructor_args={
"vk": "sys",
"initial_balances": {"sys": 1_000_000},
},
signer="sys",
)
client.submit(
shielded_commands_source,
name="con_shielded_commands",
constructor_args={
"token_contract": "currency",
"operator_address": "sys",
"root_window_size": 32,
},
signer="sys",
)
client.submit(
shielded_dex_adapter_source,
name="con_shielded_dex_adapter",
constructor_args={
"dex_contract": "con_dex",
"controller_contract": "con_shielded_commands",
"operator": "sys",
},
signer="sys",
)Step 2: Register The Verifying Keys
For a value-bearing deployment, promote ceremony-derived bundles with the xian-zk-shielded-bundle CLI and register the public manifest through the authority that owns zk_registry.
For a private test network that explicitly accepts single-party setup trust:
from xian_zk import ShieldedCommandProver
prover = ShieldedCommandProver.build_random_bundle(
contract_name="con_shielded_commands",
vk_id_prefix="private-test-command",
)
manifest = prover.registry_manifest()Register the manifest entries:
for entry in manifest["registry_entries"]:
args = dict(entry)
args.pop("action", None)
zk_registry.register_vk(**args, signer="sys")build_random_bundle() is a single-party setup, not an MPC ceremony. For local development, ShieldedCommandProver.build_insecure_dev_bundle() is available, but it must never secure real value.
Command-family ids are:
shielded-command-deposit-v5shielded-command-execute-v5shielded-command-withdraw-v5
Step 3: Bind The Keys And Allow The Adapter
commands = client.get_contract_proxy("con_shielded_commands")
for action in ("deposit", "command", "withdraw"):
commands.configure_vk(
action=action,
vk_id=prover.bundle[action]["vk_id"],
signer="sys",
)
commands.set_target_allowed(
target_contract="con_shielded_dex_adapter",
enabled=True,
signer="sys",
)
commands.set_relayer(account="relayer-1", enabled=True, signer="sys")
commands.set_relayer_restriction(enabled=True, signer="sys")Step 4: Deposit Hidden Funds
Command execution spends hidden notes from the command pool, so first deposit public funds into shielded notes:
from xian_zk import ShieldedCommandWallet
wallet = ShieldedCommandWallet.from_parts(
asset_id=commands.asset_id(signer="sys"),
owner_secret="0x...",
viewing_private_key="11" * 32,
)
deposit_plan = wallet.build_deposit(
amount=150,
old_root=commands.current_shielded_root(signer="sys"),
append_state=commands.get_tree_state(signer="sys"),
)
deposit_proof = prover.prove_deposit(deposit_plan.request)
currency = client.get_contract_proxy("currency")
currency.transfer(amount=150, to="alice", signer="sys")
currency.approve(
amount=150,
to="con_shielded_commands",
signer="alice",
)
commands.deposit_shielded(
amount=150,
old_root=deposit_proof.old_root,
output_commitments=deposit_proof.output_commitments,
proof_hex=deposit_proof.proof_hex,
output_payloads=deposit_plan.output_payloads,
signer="alice",
)After the deposit lands, sync the new note records into the local wallet before building the command plan. For this fresh local tree, the deposited note is at index 0:
wallet.sync_records([
{
"index": 0,
"commitment": deposit_proof.output_commitments[0],
"payload": deposit_plan.output_payloads[0],
}
])On an indexed network, use wallet.sync_indexed_client(...) instead of constructing records manually.
The command pool tracks shielded escrow internally. Direct token transfers to con_shielded_commands do not mint hidden notes; they show up through get_excess_balance() and the operator can recover them with sweep_excess(to=...) without changing shielded escrow.
Step 5: Build A Command Plan
Here the hidden sender wants to trade 100 units through the DEX adapter, allow a 5 unit relayer fee, and keep the remaining value as a hidden change note.
Assume chain_time was read from the latest finalized block:
import datetime
expires_at = chain_time + datetime.timedelta(minutes=10)
command_plan = wallet.build_command(
target_contract="con_shielded_dex_adapter",
relayer="relayer-1",
chain_id="xian-local-1",
fee=5,
public_amount=100,
expires_at=expires_at,
payload={
"action": "swap_exact_in",
"path": [7],
"recipient": "alice",
"amount_out_min": 95,
"deadline_seconds": 600,
},
)
command_proof = prover.prove_execute(command_plan.request)Important detail: public_amount is part of the proof statement. The adapter cannot spend more than that budget during execution. Derive expires_at from recent finalized chain time, not the local machine clock. The DEX adapter derives its own absolute deadline from contract now and the bounded deadline_seconds value.
Step 6: Relay The Command
commands.execute_command(
target_contract="con_shielded_dex_adapter",
old_root=command_proof.old_root,
input_nullifiers=command_proof.input_nullifiers,
output_commitments=command_proof.output_commitments,
proof_hex=command_proof.proof_hex,
relayer_fee=5,
public_amount=100,
payload=command_plan.request.payload,
expires_at=command_plan.request.expires_at,
output_payloads=command_plan.output_payloads,
signer="relayer-1",
environment={"chain_id": "xian-local-1"},
)The contract verifies:
- the inputs belong to a valid accepted root
- the nullifiers are unused
- the proof binds the target, payload, relayer, fee, expiry, and public spend
- the relayer and target are allowlisted
Step 7: Inspect The Result
execution = commands.get_execution(execution_id=0, signer="sys")
assert execution["target_contract"] == "con_shielded_dex_adapter"
assert execution["fee"] == 5
assert execution["public_amount"] == 100Wallet-side note recovery works through the stored output payloads and their proof-bound payload hashes.
Product Split
shielded-note-token: private value pool for private asset flowsshielded-commands: anonymous execution coordinatorshielded-dex-adapter: DEX-facing adapter for proof-bound public spendshielded-scheduler-adapter: scheduler-facing adapter for deferred actions
Caveats
- target calls and public side effects are visible
- the built-in random bundle generator is a single-party setup, not an MPC ceremony
- the DEX adapter spends only the controller token and only the exact proof-bound
public_amount