Hardening a coldkey: hashed key, Vault, multisig, pure proxy
Move an existing coldkey into a keyless pure proxy controlled by a 2-of-3 multisig — a post-quantum hashed key, a Polkadot Vault phone, and an offline key — with a narrow zero-delay proxy for daily work.
A note from me before the walkthrough. A single coldkey of mine was compromised recently. Nothing about the fix needed new chain features, only the pieces that already ship in btcli, put together carefully. This is exactly the setup I moved that key into, step by step, so you can do the same with yours. — Jacob Steeves
Every coldkey that lives as one keyfile is one leak away from a total loss: a laptop, an old backup, or a password manager is enough. Whoever holds the key can drain liquid stake, and if the key owns a subnet, they can announce a coldkey swap and take the subnet too. A transfer can't fix that, because a transfer moves TAO but not stake or subnet ownership. Only a coldkey swap moves everything.
This guide moves an existing coldkey (OLDKEY) into a structure where no single key owns anything:
PURE— a pure proxy account. It has no private key anywhere, and after the swap it holds the balance, the stake, and the subnet.MSIG— a 2-of-3 multisig, the only account that can act asPURE. Its members:QKEY— a hashed ML-DSA-65 coldkey on your machine. It's post-quantum, and its signing keys rotate.VAULT— a Polkadot Vault phone that stays offline forever and signs by QR.COLD— an offline break-glass key, such as a Ledger or a second Vault device in a safe.OPS— an everyday key holding anOwnerproxy onPUREwith zero delay. It can tune subnet hyperparameters with one signature. It cannot move TAO or stake, and it cannot rotate the owner.
flowchart TD
OLD["OLDKEY<br/>old single coldkey"] -- "announce → ~5 days → swap" --> PURE
subgraph MSIG ["MSIG — 2-of-3 multisig"]
Q["QKEY<br/>hashed ML-DSA-65"]
V["VAULT<br/>Polkadot Vault phone"]
C["COLD<br/>offline / Ledger"]
end
MSIG -- "Any, delay 0" --> PURE["PURE<br/>keyless pure proxy<br/>balance · stake · subnet"]
OPS["OPS<br/>everyday key"] -- "Owner, delay 0<br/>no transfers" --> PUREWhat this buys you:
- No single point of failure.
PUREhas no mnemonic to steal, so every action it takes needs two independent signers on different devices. - A stable address. The subnet and stake live at
PURE. If a signer is lost or rotated, you change the multisig behindPURE, and the address everyone else sees stays the same. - Post-quantum where the exposure is.
QKEYis the member that touches a networked machine, so it's the one built on ML-DSA-65 with hashed rotation.VAULTandCOLDnever go online. - Fast calls stay fast. Routine owner changes go through
OPSwith one signature, and theOwnerfilter is what keeps them from reaching funds.
Caveat — quorum strength. A multisig is as strong as its weakest quorum. Here VAULT + COLD can still reach 2-of-3 without QKEY, so the group as a whole is not quantum-resistant until at least two members are post-quantum. btcli multisig show reports this as quantum_resistant_protection. The Vault integration signs standard Sr25519 and Ed25519 only, so a Vault phone can co-sign a mixed multisig but can't itself be a hashed member.
Prerequisites
- btcli 11.3.2 or later. Step 1 covers installing it.
- About τ1 on a separate funded wallet,
FUNDER. Never use the old key for this. The money covers: - τ0.2 for the hashed-key registration deposit.
- τ0.196 per multisig opening deposit, returned when the operation executes.
- A proxy deposit.
- τ0.1 for the swap announcement.
- Fees.
- A Polkadot Vault phone. It needs app version 7.5 or later, the Bittensor network added, and a derived Bittensor key. See Signing with Polkadot Vault.
- A rehearsal. Run every step once with a throwaway
OLDKEYholding a few cents, preferably on testnet (-n test), before you touch the real key.
Commands use names from btcli's local wallets, address book, multisig book, and proxy book. Every mutation supports --dry-run, so use it first.
If OLDKEY may be compromised, move fast. The attacker holds the same key and can announce a swap to their own destination. Step 10 covers the race.
Step 1 — install btcli
btcli ships inside the bittensor package, RaoFoundation's 11.x SDK with a Rust core. One install gives you both the CLI and the Python SDK. Requirements:
- Python 3.10 to 3.14.
- A prebuilt
bittensor-corewheel for your platform. Wheels exist for macOS 10.12+ on Intel, macOS 11+ on Apple Silicon, Linux x86_64 (glibc 2.17+), and Linux aarch64 (glibc 2.28+). On any other platform, pip buildsbittensor-corefrom source with maturin, which needs a Rust toolchain. Install one from rustup.rs first.
python3 --version # 3.10 – 3.14
python3 -m venv ~/.venvs/btcli
source ~/.venvs/btcli/bin/activate
python -m pip install --upgrade pip
python -m pip install "bittensor>=11.3.2"
On macOS, a Python from python.org or Homebrew works. On Debian and Ubuntu, if python3 -m venv complains, install the python3-venv package.
Verify the version, and check that this build knows hashed keys:
btcli --version # 11.3.2 or later
btcli wallet new-coldkey --help | grep -E -- "--type|ML-DSA"
You should see --type ("standard … or hashed (rotating keys)") and ms (ML-DSA-65) under --crypto-type. If they're missing, you're on an older build.
Not the old CLI. Opentensor's legacy bittensor-cli 9.x package also installs a command called btcli. Installed into the same environment, it silently overwrites the entry point. Tell them apart by the output of btcli --version: this package prints a bare 11.x.y, while the legacy one prints BTCLI version: 9.x. pip show bittensor also lists Author: RaoFoundation. To fix a clash:
python -m pip uninstall -y bittensor-cli
python -m pip install --force-reinstall --no-deps "bittensor>=11.3.2"
btcli --version
Keep btcli in its own venv, and pin >=11.3.2, because older bittensor releases on PyPI under the same name are the pre-v11 SDK.
Vault signing needs nothing extra on the computer. --signer vault serves a local page on 127.0.0.1 that reads QR codes through your webcam. The page uses the browser's built-in QR detector and falls back to a small decoder loaded from esm.sh. All you need is a browser with camera access.
To upgrade later, watch Releases and run:
python -m pip install --upgrade "bittensor>=11.3.2"
btcli --version
Step 2 — check the chain is ready
The hashed-account feature has a network switch, AdminUtils.HashedAccountsEnabled. While it's false, QKEY can't register. btcli has no query subcommand for this flag, so read it through the SDK that comes with btcli:
python - <<'PY'
import bittensor as bt
st = bt.Subtensor(network="finney")
print("block", st.block)
print("HashedAccountsEnabled", st.query(("AdminUtils", "HashedAccountsEnabled"), []))
print("HashedAccountRegistrationDeposit (rao)", st.query(("HashedAccounts", "HashedAccountRegistrationDeposit"), []))
print("ColdkeySwapAnnouncementDelay", st.query(("SubtensorModule", "ColdkeySwapAnnouncementDelay"), []))
PY
You need HashedAccountsEnabled True. On finney it turned on at block 9,484,619 (Nov 11, 2026). At that point the deposit was 200,000,000 rao (τ0.2) and the swap delay was 36,000 blocks, about 5 days at 12 seconds per block. Plan with the live values, not these.
Step 3 — inventory the old key
A coldkey swap moves balance, stake, and subnet ownership. Proxies do not move. Record the old key's proxies now so you can re-create them on PURE, and confirm no swap is already pending:
btcli wallet inspect --coldkey OLDKEY
btcli query proxies --coldkey OLDKEY
btcli wallet swap-check -w OLDKEY
swap-check should report status none. If it shows an announcement you didn't make, go straight to Incident response.
Step 4 — create the hashed key (QKEY)
This creates an ML-DSA-65 coldkey in hashed (rotating) mode and registers it on chain. Use a clean machine and a new password that no other wallet or vault item shares.
btcli wallet new-coldkey -w QKEY --crypto-type ms --type hashed --n-words 24 \
--registration-payer FUNDER
Key creation has no --dry-run. Instead, btcli quotes the live registration deposit and asks y/n before it generates anything, and no leaves your files untouched. On yes, it registers through MEV Shield and waits for finalization. Write the 24 words down on paper and label them hashed, because a hashed wallet must be restored in the same mode.
If registration couldn't be confirmed, retry without generating new keys. Keys that are already registered aren't charged again:
btcli wallet register -w QKEY --key coldkey --registration-payer FUNDER
btcli wallet show -w QKEY should show the hashed ML-DSA type and a finalized registration.
Step 5 — add VAULT and COLD to the address book
Neither key ever exists on your computer. Save their public addresses and tag each one with how it signs:
btcli addr add VAULT --vault # scan the address QR off the Vault phone
btcli addr add COLD <COLD_SS58> --signer ledger # or vault / extension
When a later step names --signatory VAULT, btcli serves the transaction as a QR code. You scan it with Vault, check the decoded call on the phone, approve, and hold the signature QR up to the webcam. Only the unsigned transaction, the public address, and the signature cross the camera gap. The page is local, so to sign from elsewhere you would have to put it behind your own HTTPS tunnel. Plain localhost is fine for most people.
btcli addr list should show both entries with their signer tags. Check each address character by character against its device.
Step 6 — create the 2-of-3 multisig (MSIG)
btcli multisig add MSIG --threshold 2 \
--signatory QKEY --signatory VAULT --signatory COLD
btcli multisig show MSIG
New groups default to the safe derivation, so the multisig address can't double as a classical key. Keep safe unless you're re-creating an existing legacy group.
multisig show prints multisig_address and all three members. Have every member derive it independently on their own device, and continue only if they all agree.
Step 7 — fund the signers and rehearse 2-of-3
Opening a multisig operation reserves τ0.196 from the member who opens it, and the deposit comes back when the operation executes. Fund QKEY for opening deposits and fees, and fund MSIG because it pays the pure proxy's deposit in Step 8:
btcli wallet transfer -w FUNDER --dest QKEY --amount 0.5 --dry-run
btcli wallet transfer -w FUNDER --dest QKEY --amount 0.5
btcli wallet transfer -w FUNDER --dest MSIG --amount 0.5
Now rehearse the full signing flow with a tiny transfer out of MSIG. QKEY opens it and VAULT completes it:
btcli wallet transfer -w MSIG --signatory QKEY --dest FUNDER --amount 0.01 --dry-run
btcli wallet transfer -w MSIG --signatory QKEY --dest FUNDER --amount 0.01
btcli multisig pending -w MSIG # approval status + the exact co-signer command
# run the printed command for VAULT (it carries --signatory VAULT and the opening timepoint)
--signatory also takes several members in one run (--signatory "QKEY VAULT"), and btcli collects the approvals in order.
multisig pending should be empty again, with τ0.01 back on FUNDER. Repeat with COLD as the second signer. A break-glass path you've never tested isn't one you can count on.
Step 8 — spawn the pure proxy (PURE)
MSIG spawns a fresh keyless account and becomes its Any proxy with zero delay. That relationship is the only control path to PURE.
btcli proxy create -w MSIG --signatory QKEY --proxy-type Any --delay 0 --index 0 \
--name PURE --dry-run
btcli proxy create -w MSIG --signatory QKEY --proxy-type Any --delay 0 --index 0 --name PURE
btcli multisig pending -w MSIG # VAULT executes
The executing approval emits PureCreated, which carries the address plus the creation block height and extrinsic index. Record all three. btcli proxy kill needs them, and losing the spawner relationship strands the account. If --name didn't save the entry, add it by hand:
btcli proxy book add --name PURE --address <PURE_SS58> --spawner MSIG \
--proxy-type Any --delay 0 --index 0 --height <H> --ext-index <I> --account-mode <legacy|safe>
Check the result. btcli query proxies --coldkey PURE should list MSIG as an Any delegate with delay 0, and btcli wallet inspect --coldkey PURE should show no stake. A swap destination must hold no stake or locks and must not be a hotkey.
Step 9 — prove MSIG controls PURE
Before anything valuable moves, send a few cents into PURE and back out through the full multisig → proxy path:
btcli wallet transfer -w FUNDER --dest PURE --amount 0.05
btcli wallet transfer -w MSIG --signatory QKEY --proxy-for PURE --dest FUNDER --amount 0.01 --dry-run
btcli wallet transfer -w MSIG --signatory QKEY --proxy-for PURE --dest FUNDER --amount 0.01
btcli multisig pending -w MSIG # VAULT completes
If PURE's balance dropped by τ0.01, the multisig can act as PURE.
Step 10 — announce the coldkey swap
The announced coldkey swap is the only path that moves balance, stake, and subnet ownership together. The announcement commits to PURE's hash and starts the 36,000-block wait. It costs τ0.1, which is recycled, plus a fee of about τ0.027.
Fund just in time. Any TAO sitting on a compromised OLDKEY is TAO an attacker can spend announcing their destination first. Dry-run first, which also validates PURE as a destination before any fee. Then send the top-up and announce back to back:
btcli wallet announce-coldkey-swap -w OLDKEY --new-coldkey PURE --dry-run
btcli wallet transfer -w FUNDER --dest OLDKEY --amount 0.15
btcli wallet announce-coldkey-swap -w OLDKEY --new-coldkey PURE --mev-shield
--mev-shield encrypts the extrinsic, so it isn't readable in the mempool before inclusion. While the announcement is pending, the chain rejects every other signed call from OLDKEY except the swap-related ones, so the account is operationally locked for the full delay.
btcli wallet swap-check -w OLDKEY should show your announcement and the block at which it becomes executable.
Step 11 — wait, and watch
About 5 days. A delay only protects you if you watch it, so poll on a schedule much shorter than the delay:
watch -n 60 'btcli wallet swap-check -w OLDKEY --json'
The committed hash and execute block should never change. If the state flips to disputed, see Incident response.
Step 12 — execute the swap
Once the execute block has passed:
btcli wallet swap-coldkey -w OLDKEY --new-coldkey PURE --dry-run
btcli wallet swap-coldkey -w OLDKEY --new-coldkey PURE
Irreversible. The revealed key must hash to exactly what you announced. That also means an attacker who runs this step for you can only send everything to PURE.
btcli wallet inspect --coldkey PURE # balance and stake now here
btcli wallet inspect --coldkey OLDKEY # empty
btcli subnets show <NETUID> # owner is PURE, if you own a subnet
Step 13 — add the OPS proxy
Create an everyday key and grant it Owner on PURE with zero delay. Owner covers subnet-owner admin calls such as owner-settable hyperparameters and identity. It cannot transfer funds, move stake, or rotate the owner. If you don't own a subnet, grant the narrowest type that fits the job instead, such as Staking (see Proxy accounts). Never give a daily or agent key Any, NonTransfer, or NonCritical on PURE.
btcli wallet new-coldkey -w OPS
btcli proxy add -w MSIG --signatory QKEY --proxy-for PURE \
--delegate OPS --proxy-type Owner --delay 0 --dry-run
btcli proxy add -w MSIG --signatory QKEY --proxy-for PURE \
--delegate OPS --proxy-type Owner --delay 0
btcli multisig pending -w MSIG # VAULT completes
btcli wallet transfer -w FUNDER --dest OPS --amount 0.5 # fee pocket only
From here on, routine changes take one signature:
btcli tx set-hyperparameter --netuid <NETUID> --name <PARAM> --value <VALUE> \
-w OPS --proxy-for PURE --force-proxy-type Owner --dry-run
--force-proxy-type Owner makes btcli refuse any broader delegation. A few registration toggles are root-only in the Owner filter, so flip those through the full multisig.
btcli query proxies --coldkey PURE should show exactly two delegates: MSIG (Any, 0) and OPS (Owner, 0).
Step 14 — re-add old proxies, retire OLDKEY
Re-create the delegations you recorded in Step 3 on PURE with btcli proxy add -w MSIG --proxy-for PURE …, each with the narrowest type that does the job. Then retire OLDKEY: remove it from scripts, signers, and keychains, and never fund or reuse it. Keep one offline record of:
QKEY's 24 words, labeled hashed.- The
MSIGmember list and threshold. PURE's creation height and extrinsic index.
Incident response
- A swap you didn't make appears on
OLDKEY. Signbtcli tx dispute-coldkey-swap -w OLDKEYimmediately. The dispute freezes the coldkey entirely, including the attacker's swap, until root clears it withreset_coldkey_swap. It doesn't cancel anything; it stops the clock on a takeover. Then contact the network's root signers. - The attacker disputes your announcement. The account freezes the same way, and root has to reset it.
- Their swap executes. It can't be undone on chain. Prevention is the only defense: set up fast, fund just in time, and watch continuously.
- A transfer is not a fix. Moving free TAO off a leaked key leaves stake and subnet ownership behind.
Checklist
-
btcli --versionprints 11.3.2 or later, andwallet new-coldkey --helplists--typeandms (ML-DSA-65). -
HashedAccountsEnabledistrue, and you have the live deposit and swap delay. - You rehearsed the whole flow with a throwaway key.
-
OLDKEY's proxies are recorded, andswap-checkshowednonebefore you started. -
QKEYis ML-DSA-65--type hashed, registered, with a new password and its 24 words offline, labeled hashed. -
VAULTandCOLDare in the address book with signer tags, and their addresses are checked against the devices. - Every member independently derived the same
MSIGaddress. - A test transfer worked with QKEY + VAULT and with QKEY + COLD.
-
PUREwas spawned byMSIG(Any, delay 0), its height and index are recorded, and a--proxy-for PUREtest worked. -
OLDKEYwas funded just in time, and the announcement committed toPURE. - You watched the announcement for the full delay.
- The swap executed,
PUREholds everything, andOLDKEYis empty. -
OPSholds only a narrow zero-delay proxy and a small fee pocket. - The old proxies are re-added on
PURE, andOLDKEYis retired everywhere.