Concepts

The transaction model

Intents, plan and execute, Policy guardrails, and typed results.

View as Markdown

Every state change goes through one shape, whether you drive it from Python, the CLI, or an agent tool call:

  1. Describe what you want as an intent — a small serializable dataclass.
  2. plan it — fee, predicted effects, warnings, policy verdict. Nothing is submitted.
  3. execute it — sign and submit through a single policy-gated choke point, and get a typed result back.

Intents

An intent's fields are JSON-native, so it round-trips to and from a dict without custom encoders. Each intent knows its chain call, its signer (coldkey or hotkey), and its JSON schema. All 70+ of them are cataloged under Transactions, one page each.

import bittensor as sub
from bittensor.wallet import Wallet

wallet = Wallet(name="my_coldkey", hotkey="my_hotkey")
intent = sub.AddStake(hotkey_ss58="5F...validator", netuid=1, amount_tao=10)

Or build one by op name from a plain dict — how agents and tools do it:

intent = sub.intents.build("add_stake", {"hotkey_ss58": "5F...", "netuid": 1, "amount_tao": 10})

Plan: preview everything

async with sub.Client("finney") as client:
    plan = await client.plan(intent, wallet)
    plan.fee         # estimated fee (Balance)
    plan.effects     # ["stake 10 TAO to 5F... on subnet 1", ...]
    plan.warnings    # non-fatal cautions
    plan.violations  # policy violations that would block execute
    plan.ok          # no violations?

On the CLI, --dry-run on any tx command prints the same plan and submits nothing.

Execute: one choke point

result = await client.execute(intent, wallet)
result.success       # bool
result.block_hash    # where it landed

execute re-plans, enforces the policy, signs with the correct key (the intent declares whether it needs the coldkey or hotkey), submits, and decodes the receipt. client.execute_tool(op, args, wallet) is the by-name variant.

Policy: hard guardrails

A Policy bounds what any execution may do. Violations raise PolicyError at execute time and appear in every plan:

policy = sub.Policy(
    max_fee_tao=0.1,
    max_spend_tao=5.0,
    allowed_netuids=[1, 2],
    allow_raw_calls=False,   # default: raw submit_call is refused
)
async with sub.Client("finney", policy=policy) as client:
    ...

Intents whose spend cannot be bounded ahead of time (subnet registration, transfer_all, ...) report an infinite spend, so a spend cap blocks them until deliberately raised.

Typed results and errors

Failures come back as data, not prose. The error on a failed ExtrinsicResult carries the exact chain error name, a semantic ErrorCode to branch on, and a remediation hint:

result = await client.execute(sub.BurnedRegister(netuid=999), wallet)
if not result.success:
    print(result.error.name)         # "SubnetNotExists"
    print(result.error.code)         # ErrorCode.SUBNET_NOT_EXISTS
    print(result.error.remediation)  # what to try next

Every chain error name is deliberately classified — a CI gate keeps the mapping complete in both directions — so a failure never degrades to "unknown" without a reason.

Pool-level rejections

Besides dispatch errors, a transaction can be rejected before it enters the pool — the node's pre-checks fail it with Custom error: N instead of a named error, surfaced over RPC as error 1010 Invalid Transaction. The codes:

CodeNameMeaning
0ColdkeyInSwapScheduleColdkey has an announced swap pending (deprecated)
1StakeAmountTooLowAmount below the minimum stake
2BalanceTooLowNot enough free balance
3SubnetNotExistsTarget subnet does not exist
4HotkeyAccountDoesntExistHotkey account not found
5NotEnoughStakeToWithdrawUnstake exceeds the stake held
6RateLimitExceededWeight-setting or transaction rate limit hit
7InsufficientLiquidityPool cannot absorb the trade
8SlippageTooHighExecution price outside the limit
9TransferDisallowedTransfers disabled for this operation
10HotKeyNotRegisteredInNetworkHotkey not registered anywhere
11InvalidIpAddressAxon/prometheus IP invalid
12ServingRateLimitExceededAxon/prometheus serving rate limit hit
13InvalidPortAxon/prometheus port invalid
14ZeroMaxAmountComputed max amount is zero
15InvalidRevealRoundWrong reveal round for timelocked weights
16CommitNotFoundNo matching weight commit
17CommitBlockNotInRevealRangeReveal outside its window
18InputLengthsUnequalPaired input vectors differ in length
19UidNotFoundHotkey not registered on this subnet
20EvmKeyAssociateRateLimitExceededEVM key association rate limit hit
21ColdkeySwapDisputedColdkey swap under dispute
22InvalidRealAccountProxy real account invalid
23FailedShieldedTxParsingShielded payload failed to decode
24InvalidShieldedTxPubKeyHashShielded payload key hash mismatch
25NonAssociatedColdKeyColdkey does not own this hotkey
26DelegateTakeTooLowTake below the allowed minimum
27DelegateTakeTooHighTake above the allowed maximum
255BadRequestCatch-all for anything else

Code 6 covers weight-setting and general transaction rate limits; axon serving has its own code 12.

Fees

A fee-bearing extrinsic pays two components in TAO from the signer's free balance: a weight fee, linear in the call's dispatch weight, and a length fee of 1 rao per byte of the encoded extrinsic. Both are withdrawn up front, before the call runs — insufficient balance rejects the transaction outright, and a failed call does not refund them. The fee is paid to the block author, not recycled or burned (dropped in the edge case of a block with no author). plan.fee (and the --dry-run output) is this number, estimated from the chain before anything is signed.

The validator hot path is free: set-weights, commit-weights, reveal-weights and their batch and timelocked variants pay no transaction fee, as do serve-axon and take changes. For a small set of unstake-side calls (remove_stake and friends), a signer with no TAO can have the fee taken in alpha instead, converted at pool price; every other call simply requires TAO.

Staking, unstaking, and stake moves additionally pay a pool swap fee on the amount transacted — a property of the swap, not the extrinsic; see swap fees.

Rate limits

Chain rate limits are block-based cooldowns armed only by successful operations — a failed call can be retried immediately. Notable defaults (governance-settable):

Mortality

Extrinsics are signed with a mortal era: valid for 128 blocks (~25 minutes) by default, after which an unincluded transaction lapses instead of lingering in the pool indefinitely. execute and submit_call take a period= argument to change it (period=None signs an immortal transaction); MEV-shielded inner extrinsics use a shorter 8-block era — the chain rejects shielded wrappers signed with anything longer.

The CLI is the same machinery

Every intent is btcli tx <op-name>; the command's options are generated from the intent's fields, so the CLI and SDK can't diverge. Interactive sessions prompt for missing required options and confirm before submitting; --yes skips confirmation, and a non-interactive session without --yes is declined rather than left hanging.