Guides
Multisig wallets
Put a coldkey under M-of-N control so no single stolen or lost key can move your funds.
A single coldkey is a single point of failure. If it leaks — a compromised
laptop, a phished mnemonic, a malicious dependency — everything it holds is
gone, irreversibly: subnet ownership, stake, free balance. A multisig
removes that single point of failure by deriving an account address from a
set of coldkeys plus a threshold: a call only executes from that account
once M of the N signatories have approved it.
Conventionally written "M-of-N": a 2-of-3 multisig has three member keys and requires any two of them to sign. That protects you in both directions —
- Compromise: an attacker with one stolen key can open an operation but cannot execute anything. You see the pending approval and simply never co-sign it.
- Loss: with 2-of-3, losing any one key loses nothing. The remaining two can move the funds to a new account.
The signatories can be separate people (a subnet team's treasury) or separate keys held by one person on separate machines (your own coldkey, hardened). Nobody holds "the multisig key" — no such key exists. The address is pure math over the member set and threshold.
How it works
The multisig address is derived deterministically from the sorted signatory set and the threshold. The same three keys with threshold 2 always produce the same address, on any machine, with no on-chain registration step.
Spending is an approval round:
- One signatory opens an operation by submitting the full call
(
multisig-execute) or its approval (multisig-approve) with no timepoint. This reserves a deposit from the opener (returned on execution or cancellation) and records the operation on-chain under the hash of the call. - Other signatories approve, quoting the timepoint (block height and extrinsic index) of the opening approval. Every approval must describe the identical call, threshold, and signatory set — the chain matches by hash.
- Whoever brings the count to
thresholdmust submitmultisig-executewith the full call, which then dispatches as the multisig account in the same extrinsic.
Two consequences worth internalizing: the inner call's arguments must be fully explicit (it runs as the multisig, not as any signer's wallet), and because the address is derived from the member set, changing membership changes the address. If a signer leaves the team you must create a new multisig and move the funds — unless you make the members pure proxies instead of raw coldkeys, which lets you swap the human behind a member slot without changing the multisig address.
Prerequisites
uv venv && source .venv/bin/activate
uv pip install bittensorOne install gives you both btcli and the Python SDK. Practice on testnet
(-n test on every command, or btcli config set network test) with test TAO
before touching mainnet. Each signatory needs a little TAO for fees and the
opener's deposit; the multisig account itself holds the actual funds.
Step 1 — the signatory keys
For this walkthrough, a 2-of-3 with wallets alice, bob, and carol. In
production these live on different machines owned by different people; each
person only ever holds their own coldkey.
btcli wallet create -w alice # repeat for bob and carol (on their machines)Signers who are other people are just addresses to you — save them in the address book so every later command can use names instead of raw ss58:
btcli addresses add bob 5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty
btcli addresses add carol 5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy
btcli addresses listStep 2 — save the signer set and derive the address
btcli multisig add stores a named signer set in your local multisig book and
derives the on-chain address. Signatories can be wallet names, address-book
names, or raw ss58 — order doesn't matter.
btcli multisig add team-treasury --threshold 2 --signatories alice,bob,carol
btcli multisig show team-treasury # derives and prints the multisig address
btcli multisig list # all saved signer sets
btcli wallet list # multisigs appear alongside wallets on diskThe book entry is purely local convenience — every signer should run the same
multisig add on their own machine (same members, same threshold) and confirm
multisig show prints the same address. If it doesn't, someone has a
wrong member list, and funds sent there would belong to a different account.
In Python, the same derivation:
import bittensor as bt
async with bt.Subtensor("test") as client:
ms = await client.multisig(
["5Grw...alice", "5FHn...bob", "5DAA...carol"],
threshold=2,
)
print(ms.address) # deterministic in the set + thresholdStep 3 — fund and watch it
Send TAO to the derived address like any other account:
btcli addresses add team-treasury-addr 5Fmulti...sigAddress
btcli tx transfer --dest team-treasury-addr --amount-tao 100 -w aliceBalances and every other query work on the address directly — no key needed for reads:
btcli query balance --coldkey team-treasury-addrOptionally register it as a watch-only wallet (public key only, no secrets), so wallet-flavored commands work by name:
btcli wallet regen-coldkeypub -w team-treasury --ss58 5Fmulti...sigAddress
btcli wallet balance team-treasuryStep 4 — spend: open the operation
Alice proposes paying 10 TAO out of the treasury. The inner call is an intent
spec — {"op": <intent name>, ...args}, same shape as a batch child — with
fully explicit arguments. As always, --dry-run previews fee and effects
without submitting:
btcli tx multisig-execute \
--threshold 2 \
--other-signatories bob,carol \
--call '{"op": "transfer", "dest_ss58": "5DevPayee...", "amount_tao": 10}' \
-w alice --dry-run
btcli tx multisig-execute \
--threshold 2 \
--other-signatories bob,carol \
--call '{"op": "transfer", "dest_ss58": "5DevPayee...", "amount_tao": 10}' \
-w alice--other-signatories lists every member except the signer; the CLI adds
Alice's coldkey and sorts the set. Since no timepoint was passed, this opens
the operation: Alice's deposit is reserved, and nothing executes yet.
After submission the CLI prints the operation's call_hash, call_data, the
opening timepoint, and ready-to-run commands for each remaining
co-signer — copy-paste them to Bob and Carol over your team channel. The
opening extrinsic embeds the full call, so co-signers can also recover
everything from chain state alone (next step); no out-of-band call data is
required.
Step 5 — view pending operations
Any signer can inspect what's awaiting approval. With the saved multisig book entry:
btcli multisig pending --multisig team-treasury -w bobThis lists every open operation on the multisig account with its target call,
decoded parameters, approval count, who has signed, who hasn't, the timepoint,
and a copy-paste command for each remaining co-signer. Filter to one operation
with --call-hash 0x...; if the call details can't be decoded locally (e.g.
the opening block is pruned on your RPC node), pass the call_data hex once
with --call-data 0x....
The raw chain state is also one query away:
btcli query multisig --account team-treasury-addr --call-hash 0x... --jsonwhich returns the pending operation's timepoint (when), the approvals so
far, the depositor, and the reserved deposit. Check pending on a
schedule. An operation you don't recognize means a member key is
compromised — don't co-sign, and rotate that member out.
Step 6 — approve and execute
Bob was handed a ready-to-run command by pending (or by Alice). Because his
approval is the one that reaches the threshold, it must be
multisig-execute with the full call — the chain needs the call to run it.
The timepoint identifies the pending operation:
btcli tx multisig-execute \
--threshold 2 \
--other-signatories alice,carol \
--call '{"op": "transfer", "dest_ss58": "5DevPayee...", "amount_tao": 10}' \
--timepoint '{"height": 5951841, "index": 6}' \
-w bobThe transfer executes as the multisig account in this same extrinsic, and Alice's deposit is returned.
In a wider set (say 3-of-5), the signers between the first and the last can
use multisig-approve instead — it records
consent by hash only, which stays cheap even for huge calls, but never
executes. Opening approval: omit the timepoint. Intermediate approvals: pass
it. Last signer: always multisig-execute with the full call.
1-of-N: shared access without approval rounds
A threshold-1 multisig lets any single member act alone — useful as a shared operational account where you still want membership to be explicit and auditable. No approval round, timepoint, or deposit:
btcli tx multisig-threshold-1 \
--other-signatories bob,carol \
--call '{"op": "transfer", "dest_ss58": "5DevPayee...", "amount_tao": 1}' \
-w aliceNote that 1-of-N adds no compromise protection — any one stolen member key
spends. Use threshold ≥ 2 for security. See
multisig-threshold-1.
Cancel a pending operation
Only the signatory who opened the operation (and paid the deposit) can cancel it, which discards the stored approvals and returns the deposit. Threshold, signatory set, call, and timepoint must all match exactly:
btcli tx multisig-cancel \
--threshold 2 \
--other-signatories bob,carol \
--call '{"op": "transfer", "dest_ss58": "5DevPayee...", "amount_tao": 10}' \
--timepoint '{"height": 5951841, "index": 6}' \
-w aliceIf the opener's key is the compromised one, the honest members can't cancel
— but they don't need to: an operation below threshold never executes, and
the attacker's deposit stays locked until they cancel it themselves. Just
never co-sign it, and move the funds to a fresh multisig without the bad key.
See multisig-cancel.
Migrate an existing account to a multisig
If the account you want to protect already exists — a subnet owner's coldkey
especially — funding the multisig is not a migration. A
transfer moves transferable TAO and nothing else:
stake, conviction locks, hotkey registrations, and subnet ownership are
separate on-chain state that stays with the old coldkey.
(transfer-stake moves a stake position, but
still not ownership.) The tool that moves everything is the announced
coldkey swap:
# 1. Verify the destination first: every member derives the same address,
# and it must be a completely unused account — no balance history, stake,
# hotkeys, or locks.
btcli multisig show team-treasury
# 2. Announce, committing to the multisig address. Costs 0.1 TAO and locks
# the old coldkey for the chain's delay (36,000 blocks, ~5 days) — only
# swap-related calls go through while it's pending.
btcli tx announce-coldkey-swap --new-coldkey 5Fmulti...sigAddress -w old-owner
# 3. After the delay, execute. Balance, stake, locks, hotkeys, registrations,
# and subnet ownership all move to the multisig. Irreversible.
btcli tx swap-coldkey-announced --new-coldkey 5Fmulti...sigAddress -w old-ownerThe delay exists so the real owner can catch a thief's announcement
(dispute-coldkey-swap freezes the key);
check a pending one with the
coldkey-swap-announcement read.
After the swap, every owner action goes through the multisig approval round —
for routine operations like hyperparameter changes, grant a scoped proxy from
the multisig instead (next section).
Day-to-day operations: pair it with a proxy
An approval round for every hyperparameter tweak gets old fast. The standard
layering keeps the multisig as the high-impact control plane and delegates
the routine work: from the multisig, grant an operations key a narrowly
scoped proxy — Owner for a subnet owner's identity
and hyperparameter calls, Staking for a treasury that manages positions.
The grant itself is just an inner call in the usual round:
btcli tx multisig-execute \
--threshold 2 \
--other-signatories bob,carol \
--call '{"op": "add_proxy", "delegate_ss58": "5F...opsKey", "proxy_type": "Owner", "delay": 0}' \
-w aliceThe ops key now handles routine changes alone (--proxy-for the multisig
address), but cannot transfer funds or rotate the owner — and if it leaks,
the multisig convenes once to revoke it ({"op": "remove_proxy", ...}) while
the principal never moves. The full pattern is in the
proxy guide.
Any call, including sudo: btcli call --multisig
The tx multisig-* intents cover calls that have intents. For anything else —
raw pallet calls, or a chain whose sudo key is a multisig — the raw-call
escape hatch takes the same multisig flags:
btcli call SubtensorModule.some_call --args '{"netuid": 3}' \
--multisig team-treasury -w alice --yes--multisig NAME pulls the threshold and signer set from your multisig book
(inline --multisig-threshold 2 --other-signatories bob,carol works too, and
--sudo wraps the call in Sudo.sudo). After each approval the CLI prints
call_hash, call_data, the timepoint, and the exact command for each
remaining co-signer — the same flow as above, for any call the chain exposes.
The SDK
The high-level handle wraps the whole round. Each signatory calls approve
with the identical call; the approval that reaches the threshold executes it
and returns the inner call's events:
import bittensor as bt
from bittensor.wallet import Wallet
async with bt.Subtensor("test") as client:
ms = await client.multisig(
["5Grw...alice", "5FHn...bob", "5DAA...carol"],
threshold=2,
)
call = bt.calls.Balances.transfer_keep_alive(
dest="5DevPayee...",
value=10 * 10**9, # raw calls take rao (1 TAO = 1e9 rao)
)
# on Alice's machine — opens the operation
result = await ms.approve(call, Wallet(name="alice"))
# on Bob's machine — same call, reaches threshold, executes
result = await ms.approve(call, Wallet(name="bob"))
print(result.success, result.block_hash)For the intent-shaped flow (human-unit arguments, plan preview, explicit
timepoints), the Multisig* intents mirror the CLI exactly:
intent = bt.MultisigExecute(
threshold=2,
other_signatories=["5FHn...bob", "5DAA...carol"],
call={"op": "transfer", "dest_ss58": "5DevPayee...", "amount_tao": 10},
# timepoint={"height": ..., "index": ...} on every approval after the first
)
plan = await client.plan(intent, wallet) # fee, effects — nothing submitted
result = await client.execute(intent, wallet)bt.MultisigApprove, bt.MultisigCancel, and bt.MultisigThreshold1 are
the other three, and the executing approval surfaces multisig_call_hash and
multisig_call_data in result.data. Inspect pending state with the read:
pending = await client.read("multisig", account_ss58=ms.address, call_hash="0x...")
# → timepoint (when), approvals so far, depositor, reserved depositDeposits and limits
- Opening a pending operation reserves 0.132 TAO base + 0.032 TAO per unit of threshold from the opener, returned on execution or cancellation.
- A multisig can have at most 100 signatories.
- Approving twice from the same signer, or with a mismatched timepoint, threshold, or signatory set, fails — the (set, threshold, call-hash) triple is the operation's identity.
Operating it safely
- Threshold ≥ 2, always, for anything valuable. M=1 is a convenience account, not a security boundary. 2-of-3 is the usual sweet spot: survives one lost key and one stolen key.
- Independent keys. Signatories on the same laptop defeat the point. Give
each member key its own machine, and consider signing with a
Ledger (
--ledger) or a browser extension (--signer extension) so member coldkeys never sit on the machine runningbtcliat all. - Verify the address independently. Every member derives it themselves
(
btcli multisig show) before anyone funds it. - Watch
pending. A pending operation nobody proposed is your compromise alarm, and it fires before any funds move — that early warning is half the value of a multisig. Check it on a schedule, or script it with--json. - Verify before you co-sign. The
pendingoutput decodes the target and parameters — read them. Your approval is the security boundary. - Plan for membership change. Swapping a member means a new address and a funds migration, unless members are pure proxies with swappable controllers behind them.
- Test the full round on testnet — open, view, approve, cancel — before moving real funds. Fees and deposits behave identically.