From 78aa97e916c02bee0a24cf0e627947b47d32bb93 Mon Sep 17 00:00:00 2001 From: michael trestman Date: Thu, 7 May 2026 06:26:32 -0700 Subject: [PATCH 01/10] wip --- docs/keys/coldkey-swap.md | 9 + .../conviction-staking.md | 159 ++++++++++++++++++ docs/staking-and-delegation/delegation.md | 2 + docs/subnets/create-a-subnet.md | 7 + docs/subtensor-api/events.md | 15 ++ docs/subtensor-api/extrinsics.md | 29 ++++ docs/subtensor-api/rpc.md | 15 ++ sidebars.js | 1 + 8 files changed, 237 insertions(+) create mode 100644 docs/staking-and-delegation/conviction-staking.md diff --git a/docs/keys/coldkey-swap.md b/docs/keys/coldkey-swap.md index 24f4e7a5..5ad7998a 100644 --- a/docs/keys/coldkey-swap.md +++ b/docs/keys/coldkey-swap.md @@ -524,3 +524,12 @@ print(response) A coldkey swap announcement can only be cleared after the [ColdkeySwapReannouncementDelay](https://github.com/opentensor/subtensor/blob/devnet-ready/runtime/src/lib.rs#:~:text=pub%20const%20InitialColdkeySwapReannouncementDelay) period has elapsed. By default, this is 7,200 blocks (~1 day) after the initial announcement delay expires. The announcement must also not be under dispute to be cleared. ::: + +## Conviction locks and coldkey swap + +If the coldkey being swapped has [conviction locks](../staking-and-delegation/conviction-staking.md) on any subnet, the swap behavior depends on the destination coldkey's lock state: + +- **Destination coldkey has active locked mass on any subnet**: the swap is **rejected**. The destination coldkey must have no active locks before the swap can proceed. +- **Destination coldkey has only expired or zero-mass locks**: the swap proceeds. The source coldkey's locks are transferred to the destination coldkey and consolidated with any existing (zero-mass) lock records there. + +Locked mass and conviction are preserved through the swap — the lock follows the coldkey identity to the new key pair. diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md new file mode 100644 index 00000000..c7e7aa85 --- /dev/null +++ b/docs/staking-and-delegation/conviction-staking.md @@ -0,0 +1,159 @@ +--- +title: "Conviction Staking (Stake Locks)" +--- + +# Conviction staking (stake locks) + +Conviction staking lets coldkey holders lock alpha stake to a specific hotkey on a subnet. Locked stake builds **conviction** — a score that grows over time toward the locked amount — providing a public, on-chain signal of long-term commitment that cannot be silently reversed. + +The primary use case is investor confidence in subnet owners. A subnet owner whose alpha is locked has made a cryptographic commitment: unwinding a large position requires calling `unlock_stake` and then waiting through a 30-day exponential decay period before the stake can be withdrawn. This gives other stakers advance warning before any large exit completes. + +:::note Testnet launch +Conviction staking is live on testnet (spec version 403) as of May 2026 and is tentatively scheduled for mainnet on May 13, 2026. +::: + +## How locks work + +A lock binds a specific **amount** of a coldkey's alpha on a subnet to a specific **hotkey**. The lock enforces one invariant: + +> **Total alpha staked by the coldkey on that subnet ≥ locked amount** + +Everything above the locked amount is freely unstakable. The coldkey can also continue to stake additional alpha at any time — the lock only blocks the staked balance from dropping below the locked amount. + +Locks are **indefinite**: they persist until the coldkey explicitly calls `unlock_stake`. There is no expiry and no need to periodically renew a lock. + +One lock per coldkey per subnet is enforced. If a lock already exists for a coldkey on a subnet, additional `lock_stake` calls top up the locked amount (provided the hotkey matches the existing lock). + +## Conviction + +Conviction is a score that grows from zero toward the locked amount following an exponential curve: + +$$c_1 = m - (m - c_0) \cdot e^{-\Delta t / \tau}$$ + +where: +- $c_0$ — conviction at last update +- $c_1$ — conviction now +- $m$ — locked mass (alpha units) +- $\Delta t$ — blocks elapsed since last update +- $\tau$ — maturity time constant: **648,000 blocks (≈ 90 days)** + +Conviction is computed lazily — the locked mass does not change, only the evaluation time advances. No periodic transactions are required to keep conviction growing. + +**Example:** Lock 100 alpha at block 0 with no prior lock. + +| Elapsed time | Conviction | +|---|---| +| 0 days | 0 | +| 30 days | ≈ 28.3 | +| 62 days | ≈ 50.0 | +| 90 days | ≈ 63.2 | +| 180 days | ≈ 86.5 | +| 270 days | ≈ 95.0 | + +Maximum conviction equals the locked mass. Topping up an existing lock adds to locked mass immediately; conviction continues growing from its current value toward the new (higher) maximum. + +## Extrinsics + +### `lock_stake` + +``` +api.tx.subtensorModule.lockStake(hotkey, netuid, amount) +``` + +Locks `amount` alpha from the coldkey's stake on `netuid` to `hotkey`. + +- If no lock exists for this coldkey on `netuid`, a new lock is created with conviction 0. +- If a lock already exists, `amount` is added to the locked mass. The hotkey must match the existing lock — use `move_lock` first if switching hotkeys. +- `amount` must not exceed the coldkey's available (unlocked) alpha on the subnet. + +**Errors:** +- `InsufficientStakeForLock` — available alpha is less than `amount` +- `LockHotkeyMismatch` — a lock exists for a different hotkey on this subnet +- `AmountTooLow` — amount is zero + +**Event emitted:** `StakeLocked { coldkey, hotkey, netuid, amount }` + +### `unlock_stake` + +``` +api.tx.subtensorModule.unlockStake(netuid, amount) +``` + +Begins the process of unlocking `amount` alpha from the coldkey's existing lock on `netuid`. + +- Immediately reduces locked mass by `amount` and conviction by `amount`. +- The unlocked amount enters an exponential decay period. It becomes gradually withdrawable over time with a time constant of **216,000 blocks (≈ 30 days)**: roughly half is available after 30 days, ~86% after 60 days, and so on. +- While stake is in the unlocking period, it **cannot be unstaked or re-locked** — the available stake formula excludes both locked and unlocking amounts. + +**Errors:** +- `UnlockAmountTooHigh` — amount exceeds current locked mass + +**Event emitted:** `StakeUnlocked { coldkey, hotkey, netuid, amount }` + +### `move_lock` + +``` +api.tx.subtensorModule.moveLock(destination_hotkey, netuid) +``` + +Reassigns the coldkey's existing lock on `netuid` from its current hotkey to `destination_hotkey`. + +- **Conviction resets to zero** when the old and new hotkeys are owned by different coldkeys. +- Conviction is **preserved** when both hotkeys are owned by the same coldkey (moving between your own hotkeys). +- The locked mass and unlocking mass are preserved in both cases. + +This gives the previous hotkey's stakers a window to react before conviction rebuilds on the new hotkey. + +**Errors:** +- `NoExistingLock` — no lock exists for this coldkey on the subnet + +**Event emitted:** `LockMoved { coldkey, origin_hotkey, destination_hotkey, netuid }` + +## Subnet owner auto-locking + +When a subnet owner receives their distribution cut each epoch, **it is automatically locked** to the subnet owner's hotkey. If the owner already has a lock, the auto-lock tops it up using the existing lock's hotkey. If no lock exists, the auto-lock targets the subnet owner's hotkey. + +This means subnet owners start accumulating locked alpha and conviction from the moment they own a subnet. Unlocking requires a conscious `unlock_stake` transaction followed by the 30-day unlock delay. + +## Key swap behavior + +**Hotkey swap (system-level):** When a hotkey is swapped via `btcli wallet swap-hotkey`, all locks targeting the old hotkey are transferred to the new hotkey. Conviction is **not** reset, because the same coldkey owns both hotkeys. + +**Coldkey swap:** A coldkey swap fails if the destination coldkey already has **active locked mass** on any subnet. The swap succeeds if the destination coldkey only has expired or zero-mass locks — those are consolidated with the source coldkey's locks. + +## Transferring locked stake + +Locked stake can be transferred to another coldkey (e.g., for OTC sales). When stake is transferred: + +- Freely available (unlocked, not-in-unlock-period) stake transfers first. +- If the transfer amount exceeds available stake, the shortfall is drawn from unlocking stake, then from locked stake. +- Locked mass and conviction transfer proportionally. +- The lock follows the stake to the destination coldkey. + +This means a subnet owner can lock their stake and then transfer it to an investor — the investor receives the stake already locked and must wait through the unlock period before they can unstake. + +:::warning For exchanges and tools accepting alpha transfers +If your system accepts alpha stake transfers, check whether the incoming stake carries a lock. Locked alpha cannot be unstaked immediately — an unlock transaction and the 30-day decay period are required first. +::: + +## Querying conviction + +Two runtime API calls expose conviction state on-chain: + +| Method | Returns | +|---|---| +| `get_hotkey_conviction(hotkey, netuid)` | Current total conviction for `hotkey` on `netuid`, summed over all coldkeys that have locked to it | +| `get_most_convicted_hotkey_on_subnet(netuid)` | The hotkey with the highest conviction on `netuid`, or `None` if no locks exist | + +Conviction is a rolling value — querying at different blocks yields different results as time passes and the exponential grows. + +Tools like [tao.app](https://www.tao.app) and tau.stats are expected to surface per-subnet lock state, including subnet owner lock percentage and conviction scores, providing investors with at-a-glance commitment signals. + +## Storage + +Lock state is stored in two maps: + +- `Lock[(coldkey, netuid, hotkey)]` — per-coldkey lock record containing locked mass, unlocking mass, conviction score, and last update block +- `HotkeyLock[(netuid, hotkey)]` — aggregate lock totals per hotkey (used for conviction queries without iterating all coldkeys) + +The maturity time constant (`MaturityRate`) and unlock time constant (`UnlockRate`) are configurable runtime storage values, defaulting to 648,000 and 216,000 blocks respectively. diff --git a/docs/staking-and-delegation/delegation.md b/docs/staking-and-delegation/delegation.md index 94d02c79..2081a8f7 100644 --- a/docs/staking-and-delegation/delegation.md +++ b/docs/staking-and-delegation/delegation.md @@ -56,6 +56,8 @@ As a TAO holder, you will stake to a validator’s hotkey on a specific subnet. **Price protection**: Bittensor provides built-in price protection mechanisms to prevent unfavorable unstaking transactions. You can set tolerance limits and enable partial execution. See [Price Protection When Staking](../learn/price-protection.md) for more information. **Transaction fees**: Unstaking operations incur blockchain transaction fees. These fees are recycled back into the TAO emission pool. See [Transaction Fees in Bittensor](../learn/fees.md). + +**Conviction locks**: If you or the previous owner of your stake has called `lock_stake` on a subnet, a portion of your alpha may be locked and cannot be unstaked immediately. Locked stake must first be unlocked via `unlock_stake`, after which a ≈30-day exponential decay period applies before the stake is fully withdrawable. See [Conviction Staking](./conviction-staking.md) for details. ::: ### Unstaking methods diff --git a/docs/subnets/create-a-subnet.md b/docs/subnets/create-a-subnet.md index 81d7da78..c34a42ba 100644 --- a/docs/subnets/create-a-subnet.md +++ b/docs/subnets/create-a-subnet.md @@ -115,6 +115,13 @@ Output: Newly created subnets are inactive by default and do not begin emitting until they have been started by the subnet owner. This allows subnet owners to configure the subnet, register and activate validators, and onboard miners before activation. ::: +:::note Subnet owner alpha is automatically locked + +The subnet owner's share of each epoch's emissions is **automatically locked** via [conviction staking](../staking-and-delegation/conviction-staking.md). Locked alpha accrues conviction over time and cannot be unstaked immediately — unlocking requires an explicit `unlock_stake` transaction followed by a ≈30-day decay period. + +This is intentional: it gives investors a public, cryptographic signal of the owner's commitment. Tools like [tao.app](https://www.tao.app) and community dashboards are expected to surface per-subnet lock and conviction data. +::: + ### Start the subnet Use the following command to start the subnet: diff --git a/docs/subtensor-api/events.md b/docs/subtensor-api/events.md index 0fe35546..83fd5b3f 100644 --- a/docs/subtensor-api/events.md +++ b/docs/subtensor-api/events.md @@ -957,6 +957,11 @@ Generated from a live snapshot of the Subtensor runtime on **2026-04-24**. Conne - **interface**: `api.events.subtensorModule.KappaSet` - **summary**: Kappa is set for a subnet. +### `LockMoved(coldkey: AccountId, origin_hotkey: AccountId, destination_hotkey: AccountId, netuid: NetUid)` + +- **interface**: `api.events.subtensorModule.LockMoved` +- **summary**: A conviction lock has been moved from one hotkey to another. Emitted by `move_lock`. Conviction is reset to zero when the hotkeys have different owners; preserved when the same coldkey owns both. + ### `MaxAllowedUidsSet(NetUid, u16)` - **interface**: `api.events.subtensorModule.MaxAllowedUidsSet` @@ -1145,6 +1150,11 @@ Generated from a live snapshot of the Subtensor runtime on **2026-04-24**. Conne - **interface**: `api.events.subtensorModule.StakeAdded` - **summary**: stake has been transferred from the a coldkey account onto the hotkey staking account. +### `StakeLocked(coldkey: AccountId, hotkey: AccountId, netuid: NetUid, amount: AlphaBalance)` + +- **interface**: `api.events.subtensorModule.StakeLocked` +- **summary**: Alpha stake has been locked to a hotkey on a subnet. Emitted by `lock_stake` and by the automatic owner lock on each epoch's distribution. + ### `StakeMoved(AccountId, AccountId, NetUid, AccountId, NetUid, TaoBalance)` - **interface**: `api.events.subtensorModule.StakeMoved` @@ -1178,6 +1188,11 @@ Generated from a live snapshot of the Subtensor runtime on **2026-04-24**. Conne (origin_coldkey, destination_coldkey, hotkey, origin_netuid, destination_netuid, amount) +### `StakeUnlocked(coldkey: AccountId, hotkey: AccountId, netuid: NetUid, amount: AlphaBalance)` + +- **interface**: `api.events.subtensorModule.StakeUnlocked` +- **summary**: Alpha stake has been moved from the locked state into the unlock decay period. Emitted by `unlock_stake`. The stake is not immediately available — it becomes gradually withdrawable over ≈30 days. + ### `StartCallDelaySet(u64)` - **interface**: `api.events.subtensorModule.StartCallDelaySet` diff --git a/docs/subtensor-api/extrinsics.md b/docs/subtensor-api/extrinsics.md index 45216e46..e3bc5761 100644 --- a/docs/subtensor-api/extrinsics.md +++ b/docs/subtensor-api/extrinsics.md @@ -2118,6 +2118,26 @@ Generated from a live snapshot of the Subtensor runtime on **2026-04-24**. Conne - `NonAssociatedColdKey` — The hotkey we are delegating is not owned by the calling coldkey. - `DelegateTakeTooHigh` — The delegate is setting a take which is not greater than the previous. +### `lockStake(hotkey: AccountId, netuid: NetUid, amount: AlphaBalance)` + +- **interface**: `api.tx.subtensorModule.lockStake` +- **summary**: Locks `amount` alpha from the signing coldkey's stake on `netuid` to `hotkey`, building conviction over time. If no lock exists for this coldkey on `netuid`, a new lock is created with conviction 0. If a lock already exists, `amount` is added to the locked mass (the hotkey must match). The lock is indefinite — it persists until `unlock_stake` is called. See [Conviction Staking](../staking-and-delegation/conviction-staking.md) for full mechanics. + + **Errors:** + + - `InsufficientStakeForLock` — Available (unlocked) alpha is less than `amount`. + - `LockHotkeyMismatch` — A lock already exists for a different hotkey on this subnet. + - `AmountTooLow` — Amount is zero. + +### `moveLock(destination_hotkey: AccountId, netuid: NetUid)` + +- **interface**: `api.tx.subtensorModule.moveLock` +- **summary**: Moves the signing coldkey's existing lock on `netuid` from its current hotkey to `destination_hotkey`. The locked and unlocking mass are preserved. Conviction is reset to zero if the old and destination hotkeys are owned by different coldkeys; conviction is preserved if both are owned by the same coldkey. See [Conviction Staking](../staking-and-delegation/conviction-staking.md) for full mechanics. + + **Errors:** + + - `NoExistingLock` — No lock exists for this coldkey on the subnet. + ### `moveStake(origin_hotkey: AccountId, destination_hotkey: AccountId, origin_netuid: NetUid, destination_netuid: NetUid, alpha_amount: AlphaBalance)` - **interface**: `api.tx.subtensorModule.moveStake` @@ -2960,6 +2980,15 @@ Generated from a live snapshot of the Subtensor runtime on **2026-04-24**. Conne Will charge based on the weight even if the hotkey is already associated with a coldkey. +### `unlockStake(netuid: NetUid, amount: AlphaBalance)` + +- **interface**: `api.tx.subtensorModule.unlockStake` +- **summary**: Begins unlocking `amount` alpha from the signing coldkey's existing lock on `netuid`. The locked mass is reduced by `amount` immediately and the same amount enters an exponential unlock period (time constant ≈ 30 days). Stake in the unlock period cannot be unstaked or re-locked until it has decayed sufficiently. See [Conviction Staking](../staking-and-delegation/conviction-staking.md) for the decay formula. + + **Errors:** + + - `UnlockAmountTooHigh` — Amount exceeds the current locked mass. + ### `unstakeAll(hotkey: AccountId)` - **interface**: `api.tx.subtensorModule.unstakeAll` diff --git a/docs/subtensor-api/rpc.md b/docs/subtensor-api/rpc.md index 14685631..b3a11328 100644 --- a/docs/subtensor-api/rpc.md +++ b/docs/subtensor-api/rpc.md @@ -15,9 +15,24 @@ Generated from a live snapshot of the Subtensor runtime on **2026-04-24**. Conne - **[payment](#payment)** - **[rpc](#rpc)** - **[state](#state)** +- **[subtensorModule (Runtime API)](#subtensormodule-runtime-api)** - **[system](#system)** - **[web3](#web3)** +## `subtensorModule` (Runtime API) + +These methods are exposed via the Subtensor **Runtime API** and are accessed through `api.call.subtensorModuleRuntimeApi.*` rather than `api.rpc.*`. + +### `getHotkeyConviction(hotkey: AccountId32, netuid: NetUid)`: `U64F64` + +- **interface**: `api.call.subtensorModuleRuntimeApi.getHotkeyConviction` +- **summary**: Returns the current total conviction for `hotkey` on `netuid`, aggregated over all coldkeys that have locked to this hotkey. Conviction grows exponentially toward the total locked mass with a time constant of ≈90 days. See [Conviction Staking](../staking-and-delegation/conviction-staking.md). + +### `getMostConvictedHotkeyOnSubnet(netuid: NetUid)`: `Option` + +- **interface**: `api.call.subtensorModuleRuntimeApi.getMostConvictedHotkeyOnSubnet` +- **summary**: Returns the hotkey with the highest total conviction on `netuid`, or `None` if no locks exist on the subnet. Conviction is evaluated at the current block (rolled forward lazily). See [Conviction Staking](../staking-and-delegation/conviction-staking.md). + ## `author` ### `hasKey(publicKey: Bytes, keyType: Text)`: `bool` diff --git a/sidebars.js b/sidebars.js index 9f381804..fcfa5c6c 100644 --- a/sidebars.js +++ b/sidebars.js @@ -113,6 +113,7 @@ const sidebars = { "staking-and-delegation/stakers-btcli-guide", "staking-and-delegation/managing-stake-btcli", "staking-and-delegation/managing-stake-sdk", + "staking-and-delegation/conviction-staking", "keys/proxies/staking-with-proxy", { type: "category", From 3a6d383a6fa9336a04d86c0bb3a2c848497c60c6 Mon Sep 17 00:00:00 2001 From: michael trestman Date: Sun, 10 May 2026 12:05:08 -0700 Subject: [PATCH 02/10] wip --- .../conviction-staking.md | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md index c7e7aa85..de113fb6 100644 --- a/docs/staking-and-delegation/conviction-staking.md +++ b/docs/staking-and-delegation/conviction-staking.md @@ -6,7 +6,9 @@ title: "Conviction Staking (Stake Locks)" Conviction staking lets coldkey holders lock alpha stake to a specific hotkey on a subnet. Locked stake builds **conviction** — a score that grows over time toward the locked amount — providing a public, on-chain signal of long-term commitment that cannot be silently reversed. -The primary use case is investor confidence in subnet owners. A subnet owner whose alpha is locked has made a cryptographic commitment: unwinding a large position requires calling `unlock_stake` and then waiting through a 30-day exponential decay period before the stake can be withdrawn. This gives other stakers advance warning before any large exit completes. +The immediate use case is investor confidence in subnet owners. A subnet owner whose alpha is locked has made a cryptographic commitment: unwinding a large position requires calling `unlock_stake` and then waiting through an exponential decay period before the stake can be withdrawn. This gives other stakers advance warning before any large exit completes. + +Conviction is also the foundation for future **subnet governance**. The hotkey with the highest total conviction on a subnet (the "subnet king") is expected to gain voting or veto rights over subnet parameters and ownership as the system matures. The lock/conviction mechanism gives token holders a path to hold subnet owners accountable — and a slow, visible process by which control of a subnet can shift over time, rather than abruptly. :::note Testnet launch Conviction staking is live on testnet (spec version 403) as of May 2026 and is tentatively scheduled for mainnet on May 13, 2026. @@ -39,6 +41,31 @@ where: Conviction is computed lazily — the locked mass does not change, only the evaluation time advances. No periodic transactions are required to keep conviction growing. +**The core idea: conviction chases the locked amount, and the gap shrinks exponentially.** + +Rewrite the equation as: + +``` +gap = m - c0 (distance between current conviction and max) +c1 = m - gap × exp(-dt/τ) +``` + +`exp(-dt/τ)` is a number between 0 and 1 — it's the fraction of the gap that *survives* after `dt` blocks. So: + +- `dt = 0` → `exp(0) = 1` → gap unchanged → c1 = c0 ✓ +- `dt = τ` (90 days) → `exp(-1) ≈ 0.368` → 36.8% of the gap remains → you've closed ~63% of it +- `dt → ∞` → `exp(-∞) = 0` → gap gone → c1 = m ✓ + +Starting from c0 = 0 (fresh lock of 100 alpha): + +``` +gap = 100 - 0 = 100 +at 90 days: c1 = 100 - 100 × 0.368 = 63.2 +at 180 days: c1 = 100 - 100 × 0.135 = 86.5 +``` + +Conviction is always chasing `m` — getting closer every block, never quite arriving. + **Example:** Lock 100 alpha at block 0 with no prior lock. | Elapsed time | Conviction | @@ -90,6 +117,10 @@ Begins the process of unlocking `amount` alpha from the coldkey's existing lock **Event emitted:** `StakeUnlocked { coldkey, hotkey, netuid, amount }` +:::note Unlock transactions are public +Calling `unlock_stake` emits the `StakeUnlocked` event on-chain immediately, before any stake is actually withdrawable. This is by design: the unlock period exists specifically so that other stakers can observe the signal and act accordingly. An unlock by a subnet owner should be interpreted as a potential intent to reduce their position, not a completed exit. +::: + ### `move_lock` ``` @@ -109,6 +140,10 @@ This gives the previous hotkey's stakers a window to react before conviction reb **Event emitted:** `LockMoved { coldkey, origin_hotkey, destination_hotkey, netuid }` +:::note Locking does not affect emissions +Locking stake does not change the amount of emissions you receive. Emissions are determined by stake weight and consensus participation. Conviction is a governance/signaling mechanism only. +::: + ## Subnet owner auto-locking When a subnet owner receives their distribution cut each epoch, **it is automatically locked** to the subnet owner's hotkey. If the owner already has a lock, the auto-lock tops it up using the existing lock's hotkey. If no lock exists, the auto-lock targets the subnet owner's hotkey. @@ -123,17 +158,18 @@ This means subnet owners start accumulating locked alpha and conviction from the ## Transferring locked stake -Locked stake can be transferred to another coldkey (e.g., for OTC sales). When stake is transferred: +When stake is moved to another coldkey **within the same subnet**, lock obligations follow the alpha proportionally. The runtime resolves how much of the transfer carries lock state: + +1. **Freely available alpha transfers first** — alpha above the locked + unlocking amount moves with no lock implications. +2. **Unlocking alpha is drawn next** — if the transfer exceeds freely available alpha, the shortfall comes from the source's unlocking mass. That amount arrives at the destination still in its decay period. +3. **Locked alpha is drawn last** — if the transfer still exceeds what's available, the remainder comes from locked mass. Conviction transfers proportionally. This step **fails with `LockHotkeyMismatch`** if the destination coldkey already has a lock pointing at a different hotkey. -- Freely available (unlocked, not-in-unlock-period) stake transfers first. -- If the transfer amount exceeds available stake, the shortfall is drawn from unlocking stake, then from locked stake. -- Locked mass and conviction transfer proportionally. -- The lock follows the stake to the destination coldkey. +**Cross-subnet moves are different**: moving stake between subnets goes through unstake → TAO transfer → restake, which must satisfy `ensure_available_stake`. You cannot drag locked or unlocking alpha across subnets. -This means a subnet owner can lock their stake and then transfer it to an investor — the investor receives the stake already locked and must wait through the unlock period before they can unstake. +**OTC use case**: a subnet owner with all their alpha locked can transfer some of it to an investor within the same subnet. Because available alpha is zero, the transferred amount comes entirely from locked mass — the investor receives it locked, pointing at the same hotkey, and must wait through the unlock period before they can unstake. :::warning For exchanges and tools accepting alpha transfers -If your system accepts alpha stake transfers, check whether the incoming stake carries a lock. Locked alpha cannot be unstaked immediately — an unlock transaction and the 30-day decay period are required first. +If your system accepts same-subnet alpha transfers, check whether the incoming stake carries a lock. Locked alpha cannot be unstaked immediately — an unlock transaction and the subsequent decay period are required first. ::: ## Querying conviction @@ -156,4 +192,4 @@ Lock state is stored in two maps: - `Lock[(coldkey, netuid, hotkey)]` — per-coldkey lock record containing locked mass, unlocking mass, conviction score, and last update block - `HotkeyLock[(netuid, hotkey)]` — aggregate lock totals per hotkey (used for conviction queries without iterating all coldkeys) -The maturity time constant (`MaturityRate`) and unlock time constant (`UnlockRate`) are configurable runtime storage values, defaulting to 648,000 and 216,000 blocks respectively. +The maturity time constant (`MaturityRate`) and unlock time constant (`UnlockRate`) are configurable runtime storage values, defaulting to 648,000 and 216,000 blocks respectively. These values can be adjusted by governance — the unlock and maturity windows are key parameters in the mechanism's attack surface, and tuning them changes how quickly conviction can build or unwind. From e720835339ea0fd0b6739ffcb2a7374bf855c609 Mon Sep 17 00:00:00 2001 From: michael trestman Date: Sun, 10 May 2026 13:10:33 -0700 Subject: [PATCH 03/10] wip --- docs/learn/conviction-staking-deep-dive.md | 102 ++++++++++ .../conviction-staking.md | 83 ++++++++ scripts/gen_conviction_graph.py | 138 +++++++++++++ scripts/gen_conviction_lifecycle.py | 189 ++++++++++++++++++ scripts/gen_conviction_panels.py | 134 +++++++++++++ static/img/conviction-curve.svg | 72 +++++++ static/img/conviction-lifecycle.svg | 83 ++++++++ static/img/conviction-panels.svg | 82 ++++++++ 8 files changed, 883 insertions(+) create mode 100644 docs/learn/conviction-staking-deep-dive.md create mode 100644 scripts/gen_conviction_graph.py create mode 100644 scripts/gen_conviction_lifecycle.py create mode 100644 scripts/gen_conviction_panels.py create mode 100644 static/img/conviction-curve.svg create mode 100644 static/img/conviction-lifecycle.svg create mode 100644 static/img/conviction-panels.svg diff --git a/docs/learn/conviction-staking-deep-dive.md b/docs/learn/conviction-staking-deep-dive.md new file mode 100644 index 00000000..76971985 --- /dev/null +++ b/docs/learn/conviction-staking-deep-dive.md @@ -0,0 +1,102 @@ +--- +title: "Conviction Staking: Designing Trust into Bittensor" +--- + +# Conviction staking: designing trust into Bittensor + +_By Claude Sonnet — May 2026_ + +--- + +Bittensor's conviction staking mechanism ships with a compact set of extrinsics and a single exponential formula. Understanding why it's designed the way it is — not just what it does — is what makes it useful for investors, subnet participants, and tool builders. This post unpacks both. + +## The problem it solves + +Subnet ownership in Bittensor has a fundamental information problem. A subnet owner holds alpha staked to their own hotkey — but nothing prevents them from quietly reducing that position. An investor staking into a subnet where the owner has already reduced exposure to near zero is taking on risk they cannot see. + +This is sometimes called the rug-pull problem, though "silent exit" is more precise: the owner doesn't need to do anything dramatic, just unstake gradually and let their committed position shrink while the subnet continues operating and attracting external stake. + +Conviction staking addresses this with a cryptographic commitment: a subnet owner can lock their alpha stake on-chain. Once locked: + +1. The stake cannot drop below the locked amount without first calling `unlock_stake`. +2. `unlock_stake` is a **public on-chain event**, visible before any alpha becomes withdrawable. +3. After `unlock_stake`, the stake enters a decay period — roughly half is withdrawable after 30 days, ~86% after 60 days. + +Steps 2 and 3 together create an **advance notice window**. An investor watching on-chain state can observe the unlock signal and act — reducing their own exposure, moving to another subnet, or simply updating their assessment of the owner's commitment — before the owner's exit is complete. + +This is the primary purpose: not to prevent exit, but to make the process slow and public. + +## What conviction measures + +Locking stake creates a **conviction score** — a number that grows from zero toward the locked amount following an exponential curve: + +$$c_1 = m - (m - c_0) \cdot e^{-\Delta t / \tau}$$ + +where $m$ is the locked mass (alpha), $c_0$ is conviction at the last checkpoint, $\Delta t$ is elapsed blocks, and $\tau$ is 648,000 blocks (≈ 90 days). + +The intuition: conviction tracks the gap between current conviction and locked mass, and that gap shrinks exponentially. A fresh lock of 100α starts at zero conviction. After 90 days: ~63α. After 180 days: ~86α. It approaches 100α asymptotically — always getting closer, never quite arriving. + +This creates a **time cost** for conviction. You cannot lock stake today and claim full conviction tomorrow. A subnet owner claiming 90 days of high conviction has demonstrably been locked for 90 days — the chain records this and the formula is public. + +Conviction is also the foundation for a future **subnet governance** mechanism. The hotkey with the highest total conviction on a subnet (the "subnet king") is expected to gain voting or veto rights over subnet parameters as the protocol matures. Conviction therefore becomes the measure by which control of a subnet can shift — slowly, visibly, and through demonstrated long-term commitment rather than a sudden ownership transfer. + +## Two taus, two different roles + +The mechanism uses two exponential time constants, and understanding the difference between them clarifies the design. + +**Conviction tau (τ = 648,000 blocks ≈ 90 days)** governs how quickly conviction accumulates toward locked mass. This is a **design parameter** — the protocol designers chose a timescale they consider meaningful for long-term commitment. Ninety days is long enough that conviction cannot be manufactured overnight, but short enough that a genuine long-term holder builds substantial conviction within a quarter. + +**Unlock tau (τ = 216,000 blocks ≈ 30 days)** governs how quickly unlocked stake becomes withdrawable after `unlock_stake` is called. This plays a structurally different role. + +When an owner calls `unlock_stake`, observers now have three numbers: the unlocked amount, the unlock-rate constant (public, fixed), and the block of the unlock event. From those three, they can compute exactly when any fraction of that position becomes withdrawable: + +$$\text{withdrawable}(t) = \text{unlocked} \cdot (1 - e^{-\Delta t / \tau_{\text{unlock}}})$$ + +This is closer to what the perceptual psychologist David Lee called **tau** in a different sense entirely — a first-order variable in the observable stream that encodes a hidden but actionable quantity without requiring explicit knowledge of the underlying parameters. Lee showed that animals tracking an approaching object don't need to know its speed or distance; the ratio of image size to its rate of expansion gives time-to-contact directly. + +In conviction staking, a rational observer watching an unlock event doesn't need to know the owner's total position size or their intentions. The unlock amount, the rate constant, and the elapsed blocks give you "time until this stake can exit" — the variable you actually care about when deciding whether to stay in a subnet. The unlock period is precisely designed so that this information is available and actionable before the exit completes. + +In short: conviction tau is the designer's statement about what commitment means temporally. Unlock tau is the observer's tool for computing time-to-exit. + +## Subnet owners start locked by default + +One detail that changes the practical picture: subnet owners don't have to remember to lock their stake. When an owner receives their distribution cut each epoch, **it is automatically locked** to their hotkey. From the moment someone registers a subnet, their owner cut begins accumulating as locked alpha, and conviction begins growing from zero. + +Unlocking requires a conscious, explicit `unlock_stake` transaction. This flips the default: owners are locked until they choose otherwise, rather than unlocked until they choose to lock. An absence of lock state on a subnet owner's hotkey is therefore a meaningful signal — it means the owner has taken an active step to unlock, not simply that they never engaged with the feature. + +## The commitment ladder + +The combination of auto-locking, conviction growth, and unlock delay creates a natural commitment ladder that investors can read from chain state: + +| Signal | What it means | +|---|---| +| No lock on owner hotkey | Owner has explicitly unlocked, or is newly onboarded and unlock was intentional | +| Lock exists, conviction < 30% | Recent or recently topped-up lock; commitment is new | +| Lock exists, conviction > 63% | Owner has been continuously locked for at least one time constant (90 days) | +| `unlock_stake` event emitted | Owner has signaled intent to reduce position; exit begins now | +| High conviction + large locked mass | Demonstrated long-term commitment; costly to reverse quickly | + +Tools like [tao.app](https://www.tao.app) and tau.stats are building interfaces to surface this data per subnet. The mechanism only works as an investor signal if the data is visible — the protocol makes it available on-chain, but ecosystem tooling is what makes it legible at a glance. + +## A note on what conviction does not do + +Conviction is a governance and signaling mechanism. It does **not** affect emissions. Locking more stake, holding it longer, or achieving maximum conviction does not change how much alpha you earn — emissions continue to be determined by stake weight and consensus participation. + +This is intentional. Conflating conviction with emission weight would distort the incentive: owners would lock not because they believe in the subnet but because it increases their yield. Keeping them separate means conviction is a credible signal of belief — someone who locks alpha and waits 90 days is expressing long-term confidence in the subnet, not optimizing a reward formula. + +## Using this as a builder + +If you're building tooling that interacts with alpha transfers within a subnet, one implementation detail matters: locked and unlocking alpha travels with same-subnet stake transfers. The runtime applies a priority order — freely available alpha moves first, then unlocking alpha, then locked alpha — and a transfer that must draw from locked mass will fail if the destination coldkey's existing lock points to a different hotkey (`LockHotkeyMismatch`). + +For exchanges and wallets that accept alpha transfers: check lock state before accepting. Alpha that arrives locked cannot be immediately unstaked. An `unlock_stake` call followed by the 30-day decay period is required before that stake becomes liquid. + +Two runtime API calls are available for querying conviction state: + +- `get_hotkey_conviction(hotkey, netuid)` — total conviction for a hotkey on a subnet, summed across all locking coldkeys +- `get_most_convicted_hotkey_on_subnet(netuid)` — the current "subnet king" by conviction + +Conviction is a rolling value that changes every block — query at the current block for the current value, or apply the formula to project forward given the stored checkpoint. + +--- + +The full reference documentation, including extrinsic signatures, error types, storage layout, and the implementation appendix showing how the checkpoint system works in code, is in [Conviction Staking](../staking-and-delegation/conviction-staking). diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md index de113fb6..626da3c9 100644 --- a/docs/staking-and-delegation/conviction-staking.md +++ b/docs/staking-and-delegation/conviction-staking.md @@ -41,6 +41,17 @@ where: Conviction is computed lazily — the locked mass does not change, only the evaluation time advances. No periodic transactions are required to keep conviction growing. +![Conviction growth and unlock availability, side by side](/img/conviction-panels.svg) + +_Left — Conviction growth: `f(t) = 1 − exp(−t / τ)`, τ = 648,000 blocks ≈ 90 days. Dot marks one time constant (63.2% of max)._ +_Right — Unlock availability: `f(t) = 1 − exp(−t / τ)`, τ = 216,000 blocks ≈ 30 days. Dot marks one time constant (63.2% of unlocked amount available). Both x-axes span 3τ._ + +The same formula governs both curves — only the time constant differs. The lifecycle graph below shows how they interact in sequence: + +![Conviction lifecycle: lock then unlock](/img/conviction-lifecycle.svg) + +_Scenario: lock 100α at day 0; call `unlock_stake(50α)` at day 90. Conviction (blue) drops instantly by the unlocked amount and then rebuilds toward the new lower ceiling. Unlocked α (orange) becomes gradually withdrawable over the following ~30 days._ + **The core idea: conviction chases the locked amount, and the gap shrinks exponentially.** Rewrite the equation as: @@ -193,3 +204,75 @@ Lock state is stored in two maps: - `HotkeyLock[(netuid, hotkey)]` — aggregate lock totals per hotkey (used for conviction queries without iterating all coldkeys) The maturity time constant (`MaturityRate`) and unlock time constant (`UnlockRate`) are configurable runtime storage values, defaulting to 648,000 and 216,000 blocks respectively. These values can be adjusted by governance — the unlock and maturity windows are key parameters in the mechanism's attack surface, and tuning them changes how quickly conviction can build or unwind. + +## Appendix: implementation — lazy evaluation and checkpointing + +The conviction formula is closed-form — no iteration, no history — because the runtime stores only a checkpoint at the last mutation and evaluates forward on demand. + +**What's stored** (`LockState`, `lib.rs`): + +```rust +pub struct LockState { + pub locked_mass: AlphaBalance, // constant between user actions + pub unlocked_mass: AlphaBalance, // amount pending the 30-day decay + pub conviction: U64F64, // c0: conviction at last_update + pub last_update: u64, // block number of last write +} +``` + +No history. Just a snapshot at a single block. The four fields are sufficient to reconstruct conviction at any future block. + +**The formula** (`calculate_matured_values`, `lock.rs`): + +```rust +let decay = Self::exp_decay(dt, tau); // exp(-dt/tau) +let new_conviction = + mass_fixed.saturating_sub( + decay.saturating_mul(mass_fixed.saturating_sub(conviction)) + ); +// = m - exp(-dt/tau) * (m - c0) +``` + +One call, no loop. This is the same equation shown in the [Conviction](#conviction) section above. + +**On-demand evaluation** (`roll_forward_lock`, `lock.rs`): + +```rust +pub fn roll_forward_lock(lock: LockState, now: u64) -> LockState { + let dt = now.saturating_sub(lock.last_update); + let (new_unlocked_mass, new_conviction) = + Self::calculate_matured_values( + lock.locked_mass, lock.unlocked_mass, lock.conviction, dt, + ); + LockState { + locked_mass: lock.locked_mass, + unlocked_mass: new_unlocked_mass, + conviction: new_conviction, + last_update: now, + } +} +``` + +**The mutation pattern** (from `do_lock_stake`, `lock.rs`): + +```rust +// Roll to current block before modifying +let lock = Self::roll_forward_lock(existing, now); +let new_locked = lock.locked_mass.saturating_add(amount); +Self::insert_lock_state(coldkey, netuid, hotkey, LockState { + locked_mass: new_locked, + unlocked_mass: lock.unlocked_mass, + conviction: lock.conviction, // current conviction becomes new c0 + last_update: now, // checkpoint resets to now +}); +``` + +Every mutation — `lock_stake`, `unlock_stake`, `move_lock` — calls `roll_forward_lock` first. This advances conviction to the current block and writes it as the new `c0`. From that point, the stored `(c0, m, last_update)` triple is sufficient to evaluate conviction at any future block without needing history. + +Conviction is therefore a pure function of elapsed time between mutations. Given the stored checkpoint, conviction at any future block `b` is: + +``` +c(b) = m - (m - c0) × exp(-(b - last_update) / τ) +``` + +This is exactly what `get_hotkey_conviction` evaluates when queried. You can also project forward: if no mutations occur between now and block `b`, the formula gives the exact future conviction. A mutation (top-up, partial unlock, `move_lock`) resets `c0` and `last_update` to a new checkpoint, restarting the forecast from there. diff --git a/scripts/gen_conviction_graph.py b/scripts/gen_conviction_graph.py new file mode 100644 index 00000000..89b9a115 --- /dev/null +++ b/scripts/gen_conviction_graph.py @@ -0,0 +1,138 @@ +""" +Generate conviction-curve.svg for the conviction staking docs page. + +Two curves, both normalized to fraction of maximum (0→1): + - Conviction growth: f(t) = 1 - exp(-t / 90) τ = 648,000 blocks ≈ 90 days + - Unlock availability: f(t) = 1 - exp(-t / 30) τ = 216,000 blocks ≈ 30 days + +Output: ../static/img/conviction-curve.svg +""" +import math +import os + +# ── layout ────────────────────────────────────────────────────────────────── +W, H = 720, 400 +ML, MR, MT, MB = 65, 30, 45, 55 # margins +PW = W - ML - MR # plot width = 625 +PH = H - MT - MB # plot height = 300 + +X_MAX = 270 # days on x-axis +Y_MAX = 1.0 + +def px(day): + return ML + (day / X_MAX) * PW + +def py(frac): + return MT + PH - (frac / Y_MAX) * PH + +# ── curves ─────────────────────────────────────────────────────────────────── +N = 800 + +def conviction(t): + return 1 - math.exp(-t / 90) + +def unlock_access(t): + return 1 - math.exp(-t / 30) + +pts_conv = [(i * X_MAX / N, conviction(i * X_MAX / N)) for i in range(N + 1)] +pts_unlock = [(i * X_MAX / N, unlock_access(i * X_MAX / N)) for i in range(N + 1)] + +def polyline(pts): + return " ".join(f"{px(x):.2f},{py(y):.2f}" for x, y in pts) + +# ── colours ────────────────────────────────────────────────────────────────── +BLUE = "#3b82f6" +ORANGE = "#f97316" +AXIS = "#374151" +GRID = "#e5e7eb" +TEXT = "#111827" +MUTED = "#6b7280" + +# ── build ──────────────────────────────────────────────────────────────────── +o = [] + +def l(s): + o.append(s) + +l(f'') +l(f' ') + +# grid +for yf in [0.2, 0.4, 0.6, 0.8, 1.0]: + y = py(yf) + l(f' ') +for xd in range(0, 271, 30): + x = px(xd) + l(f' ') + +# reference line at 1 - 1/e ≈ 63.2% (one time-constant level) +y63 = py(1 - 1/math.e) +l(f' ') +l(f' 63.2% (one τ)') + +# reference verticals at each τ +for xd, col in [(30, ORANGE), (90, BLUE)]: + x = px(xd) + l(f' ') + +# curves (unlock on top so it's not hidden by conviction) +l(f' ') +l(f' ') + +# dots at the τ crossing points +dot_r = 4 +for (xd, func, col) in [(30, unlock_access, ORANGE), (90, conviction, BLUE)]: + x, y = px(xd), py(func(xd)) + l(f' ') + +# axes +l(f' ') +l(f' ') + +# x ticks + labels +for xd in range(0, 271, 30): + x = px(xd) + yb = MT + PH + l(f' ') + l(f' {xd}') + +# y ticks + labels +for yf in [0, 0.2, 0.4, 0.6, 0.8, 1.0]: + y = py(yf) + l(f' ') + l(f' {yf:.1f}') + +# axis titles +l(f' Days since lock / unlock event') +l(f' Fraction of maximum') + +# chart title +l(f' Conviction Growth & Unlock Availability') + +# legend (top-right inside plot) +lx = ML + PW - 195 +ly = MT + 18 + +l(f' ') + +l(f' ') +l(f' ') +l(f' Conviction growth') +l(f' τ = 648,000 blocks (≈ 90 days)') + +l(f' ') +l(f' ') +l(f' Unlock availability') +l(f' τ = 216,000 blocks (≈ 30 days)') + +l('') + +# ── write ───────────────────────────────────────────────────────────────────── +out = os.path.join(os.path.dirname(__file__), "../static/img/conviction-curve.svg") +with open(out, "w") as f: + f.write("\n".join(o)) + +print(f"Written: {os.path.abspath(out)}") diff --git a/scripts/gen_conviction_lifecycle.py b/scripts/gen_conviction_lifecycle.py new file mode 100644 index 00000000..cb96bb0f --- /dev/null +++ b/scripts/gen_conviction_lifecycle.py @@ -0,0 +1,189 @@ +""" +Generate conviction-lifecycle.svg — a single sequential timeline showing: + + Scenario: lock 100α at day 0, call unlock_stake(50α) at day 90. + + Phase 1 (day 0-90): + - Conviction grows from 0 toward 100α ceiling: c(t) = 100·(1−exp(−t/90)) + - At day 90, conviction ≈ 63.2α + + Unlock event (day 90): + - locked_mass: 100 → 50 + - conviction: 63.2 → 13.2 (drops by 50, the unlocked amount) + - unlocked_mass: 0 → 50 (enters 30-day decay period) + + Phase 2 (day 90-300): + - Conviction rebuilds from 13.2 toward new ceiling 50: + c(t) = 50 − (50−13.2)·exp(−(t−90)/90) + - Unlocked α becomes available: + available(t) = 50·(1−exp(−(t−90)/30)) + +Output: ../static/img/conviction-lifecycle.svg +""" +import math, os + +# ── scenario constants ──────────────────────────────────────────────────────── +LOCK_AMOUNT = 100 # initial lock +UNLOCK_AMOUNT = 50 # amount unlocked at UNLOCK_DAY +UNLOCK_DAY = 90 +TAU_CONV = 90 # conviction τ (days) +TAU_UNLOCK = 30 # unlock τ (days) +X_MAX = 300 + +c_at_unlock = LOCK_AMOUNT * (1 - math.exp(-UNLOCK_DAY / TAU_CONV)) # ≈ 63.2 +c_after_unlock = c_at_unlock - UNLOCK_AMOUNT # ≈ 13.2 +locked_after = LOCK_AMOUNT - UNLOCK_AMOUNT # = 50 + +def conviction(t): + if t <= UNLOCK_DAY: + return LOCK_AMOUNT * (1 - math.exp(-t / TAU_CONV)) + else: + dt = t - UNLOCK_DAY + return locked_after - (locked_after - c_after_unlock) * math.exp(-dt / TAU_CONV) + +def unlock_avail(t): + if t < UNLOCK_DAY: + return 0 + dt = t - UNLOCK_DAY + return UNLOCK_AMOUNT * (1 - math.exp(-dt / TAU_UNLOCK)) + +# ── layout ─────────────────────────────────────────────────────────────────── +W, H = 720, 400 +MT, MB = 52, 60 +ML, MR = 65, 28 +PW = W - ML - MR # 627 +PH = H - MT - MB # 288 +Y_MAX = 110 # a bit above 100 for headroom + +def px(day): return ML + (day / X_MAX) * PW +def py(alpha): return MT + PH * (1 - alpha / Y_MAX) + +N = 800 +conv_pts = [(t, conviction(t)) for t in (i * X_MAX / N for i in range(N + 1))] +unlock_pts = [(t, unlock_avail(t)) for t in (i * X_MAX / N for i in range(N + 1))] + +def polyline(pts): + return " ".join(f"{px(x):.2f},{py(y):.2f}" for x, y in pts) + +BLUE = "#3b82f6" +ORANGE = "#f97316" +AXIS = "#374151" +GRID = "#e5e7eb" +TEXT = "#111827" +MUTED = "#6b7280" +RED = "#ef4444" + +o = [] +def l(s): o.append(s) + +l(f'') +l(f' ') + +# ── phase background shading ────────────────────────────────────────────────── +x_unlock = px(UNLOCK_DAY) +l(f' ') # blue tint +l(f' ') # orange tint + +# ── grid ────────────────────────────────────────────────────────────────────── +for alpha in [25, 50, 75, 100]: + y = py(alpha) + l(f' ') +for xd in range(0, X_MAX + 1, 30): + x = px(xd) + l(f' ') + +# ── locked mass ceiling lines (dashed) ─────────────────────────────────────── +# Phase 1: ceiling at 100 +y100 = py(LOCK_AMOUNT) +l(f' ') +l(f' lock ceiling 100α') + +# Phase 2: ceiling at 50 +y50 = py(locked_after) +l(f' ') +l(f' lock ceiling 50α') + +# ── unlock event vertical line ──────────────────────────────────────────────── +l(f' ') + +# event label +l(f' unlock_stake(50α)') +l(f' day {UNLOCK_DAY}') + +# ── conviction drop annotation ──────────────────────────────────────────────── +# Arrow from pre-unlock conviction to post-unlock conviction at the unlock day +y_pre = py(c_at_unlock) +y_post = py(c_after_unlock) +l(f' ') + +# ── arrow marker def ────────────────────────────────────────────────────────── +l(f' ') +l(f' ') +l(f' ') +l(f' ') +l(f' ') + +# conviction drop label +x_lbl = x_unlock - 85 +l(f' −50α conviction') + +# ── curves ──────────────────────────────────────────────────────────────────── +# draw orange first so blue sits on top +l(f' ') +l(f' ') + +# ── axes ────────────────────────────────────────────────────────────────────── +yb = MT + PH +l(f' ') +l(f' ') + +# x ticks + labels +for xd in range(0, X_MAX + 1, 30): + x = px(xd) + l(f' ') + l(f' {xd}') + +# y ticks + labels +for alpha in [0, 25, 50, 75, 100]: + y = py(alpha) + l(f' ') + l(f' {alpha}') + +# axis titles +xmid = ML + PW / 2 +l(f' Days') +ymid = MT + PH / 2 +l(f' Alpha (α)') + +# chart title +l(f' Conviction Lifecycle: Lock then Unlock') +l(f' Scenario: lock 100α at day 0, call unlock_stake(50α) at day 90') + +# ── phase labels ────────────────────────────────────────────────────────────── +x_p1_mid = px(UNLOCK_DAY / 2) +x_p2_mid = px(UNLOCK_DAY + (X_MAX - UNLOCK_DAY) / 2) +l(f' Phase 1 — conviction builds') +l(f' Phase 2 — unlock period + conviction rebuilds') + +# ── legend ──────────────────────────────────────────────────────────────────── +lx, ly = ML + PW - 205, MT + PH - 90 +l(f' ') +l(f' ') +l(f' Conviction score') +l(f' ') +l(f' Unlocked α available to withdraw') +l(f' ') +l(f' Lock ceiling (max conviction)') + +l('') + +out = os.path.join(os.path.dirname(__file__), "../static/img/conviction-lifecycle.svg") +with open(out, "w") as f: + f.write("\n".join(o)) +print(f"Written: {os.path.abspath(out)}") diff --git a/scripts/gen_conviction_panels.py b/scripts/gen_conviction_panels.py new file mode 100644 index 00000000..f94558d2 --- /dev/null +++ b/scripts/gen_conviction_panels.py @@ -0,0 +1,134 @@ +""" +Generate conviction-panels.svg — two clean side-by-side charts: + Left: Conviction growth f(t) = 1 − exp(−t / 90) τ = 90 days + Right: Unlock availability f(t) = 1 − exp(−t / 30) τ = 30 days + +Both y-axes run 0→1 (fraction of maximum). Each x-axis spans 3τ. +Output: ../static/img/conviction-panels.svg +""" +import math, os + +# ── layout ─────────────────────────────────────────────────────────────────── +W, H = 720, 370 +MT, MB = 50, 72 # top / bottom margins +ML, MR = 62, 22 # outer left / right margins +GAP = 28 # gap between panels +PH = H - MT - MB # plot height = 248 +PW = (W - ML - MR - GAP) // 2 # each panel plot width = 304 + +P1X = ML # left panel plot-area left edge +P2X = ML + PW + GAP # right panel plot-area left edge + +X1, X2 = 270, 90 # x-range for each panel (3τ each) + +def x1(d): return P1X + (d / X1) * PW +def x2(d): return P2X + (d / X2) * PW +def yy(f): return MT + PH * (1 - f) + +N = 600 +conv_pts = [(d, 1 - math.exp(-d / 90)) for d in (i * X1 / N for i in range(N + 1))] +unlock_pts = [(d, 1 - math.exp(-d / 30)) for d in (i * X2 / N for i in range(N + 1))] + +def polyline(pts, xfn): + return " ".join(f"{xfn(x):.2f},{yy(y):.2f}" for x, y in pts) + +BLUE = "#3b82f6" +ORANGE = "#f97316" +AXIS = "#374151" +GRID = "#e5e7eb" +TEXT = "#111827" +MUTED = "#6b7280" + +o = [] +def l(s): o.append(s) + +l(f'') +l(f' ') + +# ── helper: draw one panel ──────────────────────────────────────────────────── +def panel(pxfn, x_max, x_ticks, tau_day, color, pts, label, formula): + # grid + for yf in [0.2, 0.4, 0.6, 0.8, 1.0]: + y = yy(yf) + x_left = pxfn(0) + x_right = pxfn(x_max) + l(f' ') + for xd in x_ticks: + x = pxfn(xd) + l(f' ') + + # 63.2% reference + y63 = yy(1 - 1/math.e) + x_left = pxfn(0) + x_right = pxfn(x_max) + l(f' ') + l(f' 63.2%') + + # τ vertical reference + x_tau = pxfn(tau_day) + l(f' ') + + # curve + l(f' ') + + # dot at (τ, 63.2%) + l(f' ') + + # axes + x0 = pxfn(0) + xn = pxfn(x_max) + yb = MT + PH + l(f' ') + l(f' ') + + # x ticks + labels + for xd in x_ticks: + x = pxfn(xd) + l(f' ') + l(f' {xd}') + + # x axis label + xmid = pxfn(x_max / 2) + l(f' Days') + + # panel title + l(f' {label}') + + # formula below title + l(f' {formula}') + +# ── left panel (conviction) ─────────────────────────────────────────────────── +panel(x1, X1, [0, 90, 180, 270], 90, BLUE, conv_pts, + "Conviction Growth", + "f(t) = 1 − exp(−t / 90) τ = 648,000 blocks ≈ 90 days") + +# ── right panel (unlock) ────────────────────────────────────────────────────── +panel(x2, X2, [0, 30, 60, 90], 30, ORANGE, unlock_pts, + "Unlock Availability", + "f(t) = 1 − exp(−t / 30) τ = 216,000 blocks ≈ 30 days") + +# ── shared y-axis label ─────────────────────────────────────────────────────── +ymid = MT + PH / 2 +l(f' Fraction of maximum') + +# ── y ticks on left panel only ──────────────────────────────────────────────── +for yf in [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]: + y = yy(yf) + l(f' ') + l(f' {yf:.1f}') + +# ── y ticks on right panel ──────────────────────────────────────────────────── +for yf in [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]: + y = yy(yf) + l(f' ') + l(f' {yf:.1f}') + +l('') + +out = os.path.join(os.path.dirname(__file__), "../static/img/conviction-panels.svg") +with open(out, "w") as f: + f.write("\n".join(o)) +print(f"Written: {os.path.abspath(out)}") diff --git a/static/img/conviction-curve.svg b/static/img/conviction-curve.svg new file mode 100644 index 00000000..b76a6fa7 --- /dev/null +++ b/static/img/conviction-curve.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + 63.2% (one τ) + + + + + + + + + + 0 + + 30 + + 60 + + 90 + + 120 + + 150 + + 180 + + 210 + + 240 + + 270 + + 0.0 + + 0.2 + + 0.4 + + 0.6 + + 0.8 + + 1.0 + Days since lock / unlock event + Fraction of maximum + Conviction Growth & Unlock Availability + + + + Conviction growth + τ = 648,000 blocks (≈ 90 days) + + + Unlock availability + τ = 216,000 blocks (≈ 30 days) + \ No newline at end of file diff --git a/static/img/conviction-lifecycle.svg b/static/img/conviction-lifecycle.svg new file mode 100644 index 00000000..c7bea038 --- /dev/null +++ b/static/img/conviction-lifecycle.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + lock ceiling 100α + + lock ceiling 50α + + unlock_stake(50α) + day 90 + + + + + + + −50α conviction + + + + + + 0 + + 30 + + 60 + + 90 + + 120 + + 150 + + 180 + + 210 + + 240 + + 270 + + 300 + + 0 + + 25 + + 50 + + 75 + + 100 + Days + Alpha (α) + Conviction Lifecycle: Lock then Unlock + Scenario: lock 100α at day 0, call unlock_stake(50α) at day 90 + Phase 1 — conviction builds + Phase 2 — unlock period + conviction rebuilds + + + Conviction score + + Unlocked α available to withdraw + + Lock ceiling (max conviction) + \ No newline at end of file diff --git a/static/img/conviction-panels.svg b/static/img/conviction-panels.svg new file mode 100644 index 00000000..0b4ce65a --- /dev/null +++ b/static/img/conviction-panels.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + 63.2% + + + + + + + 0 + + 90 + + 180 + + 270 + Days + Conviction Growth + f(t) = 1 − exp(−t / 90) τ = 648,000 blocks ≈ 90 days + + + + + + + + + + + 63.2% + + + + + + + 0 + + 30 + + 60 + + 90 + Days + Unlock Availability + f(t) = 1 − exp(−t / 30) τ = 216,000 blocks ≈ 30 days + Fraction of maximum + + 0.0 + + 0.2 + + 0.4 + + 0.6 + + 0.8 + + 1.0 + + 0.0 + + 0.2 + + 0.4 + + 0.6 + + 0.8 + + 1.0 + \ No newline at end of file From 81db3ae1a157389fd6a7e33434dcc981054ee031 Mon Sep 17 00:00:00 2001 From: michael trestman Date: Sun, 10 May 2026 13:22:35 -0700 Subject: [PATCH 04/10] wip --- docs/learn/conviction-staking-deep-dive.md | 18 ++---------------- .../conviction-staking.md | 2 +- sidebars.js | 1 + 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/docs/learn/conviction-staking-deep-dive.md b/docs/learn/conviction-staking-deep-dive.md index 76971985..da3e7a93 100644 --- a/docs/learn/conviction-staking-deep-dive.md +++ b/docs/learn/conviction-staking-deep-dive.md @@ -4,27 +4,13 @@ title: "Conviction Staking: Designing Trust into Bittensor" # Conviction staking: designing trust into Bittensor -_By Claude Sonnet — May 2026_ - ---- - -Bittensor's conviction staking mechanism ships with a compact set of extrinsics and a single exponential formula. Understanding why it's designed the way it is — not just what it does — is what makes it useful for investors, subnet participants, and tool builders. This post unpacks both. - -## The problem it solves - Subnet ownership in Bittensor has a fundamental information problem. A subnet owner holds alpha staked to their own hotkey — but nothing prevents them from quietly reducing that position. An investor staking into a subnet where the owner has already reduced exposure to near zero is taking on risk they cannot see. This is sometimes called the rug-pull problem, though "silent exit" is more precise: the owner doesn't need to do anything dramatic, just unstake gradually and let their committed position shrink while the subnet continues operating and attracting external stake. -Conviction staking addresses this with a cryptographic commitment: a subnet owner can lock their alpha stake on-chain. Once locked: - -1. The stake cannot drop below the locked amount without first calling `unlock_stake`. -2. `unlock_stake` is a **public on-chain event**, visible before any alpha becomes withdrawable. -3. After `unlock_stake`, the stake enters a decay period — roughly half is withdrawable after 30 days, ~86% after 60 days. - -Steps 2 and 3 together create an **advance notice window**. An investor watching on-chain state can observe the unlock signal and act — reducing their own exposure, moving to another subnet, or simply updating their assessment of the owner's commitment — before the owner's exit is complete. +Conviction staking addresses this by introducing a cryptographic metric for **commitment**, based on a new mechanism whereby stakers can **lock** their stake to a subnet. Once locked, stake must be unlocked (an on-chain operation which is therefore public information) before it can be unstaked. Unlocked becomes available to unstake all only gradually, with 50% availabe after ~30 days and 85% available at 60 days. -This is the primary purpose: not to prevent exit, but to make the process slow and public. +Gradual unlock-and-release process gives investors a period to respond to planned exits by subnet owners or other major investors. ## What conviction measures diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md index 626da3c9..441a51c5 100644 --- a/docs/staking-and-delegation/conviction-staking.md +++ b/docs/staking-and-delegation/conviction-staking.md @@ -8,7 +8,7 @@ Conviction staking lets coldkey holders lock alpha stake to a specific hotkey on The immediate use case is investor confidence in subnet owners. A subnet owner whose alpha is locked has made a cryptographic commitment: unwinding a large position requires calling `unlock_stake` and then waiting through an exponential decay period before the stake can be withdrawn. This gives other stakers advance warning before any large exit completes. -Conviction is also the foundation for future **subnet governance**. The hotkey with the highest total conviction on a subnet (the "subnet king") is expected to gain voting or veto rights over subnet parameters and ownership as the system matures. The lock/conviction mechanism gives token holders a path to hold subnet owners accountable — and a slow, visible process by which control of a subnet can shift over time, rather than abruptly. +For a deeper look, see [Conviction Staking: Designing Trust into Bittensor](../learn/conviction-staking-deep-dive). :::note Testnet launch Conviction staking is live on testnet (spec version 403) as of May 2026 and is tentatively scheduled for mainnet on May 13, 2026. diff --git a/sidebars.js b/sidebars.js index fcfa5c6c..1d2ba6bc 100644 --- a/sidebars.js +++ b/sidebars.js @@ -55,6 +55,7 @@ const sidebars = { "learn/ema", "learn/yuma-consensus", "learn/yc3-blog", + "learn/conviction-staking-deep-dive", "concepts/weight-copying-in-bittensor", "learn/yuma3-migration-guide", "learn/fees", From b52422834d654672226ecb66f3fd83efd94d7fc8 Mon Sep 17 00:00:00 2001 From: michael trestman Date: Sun, 10 May 2026 19:08:06 -0700 Subject: [PATCH 05/10] wip --- docs/learn/conviction-staking-deep-dive.md | 88 ------------------- .../conviction-staking.md | 20 ++--- sidebars.js | 3 +- 3 files changed, 11 insertions(+), 100 deletions(-) delete mode 100644 docs/learn/conviction-staking-deep-dive.md diff --git a/docs/learn/conviction-staking-deep-dive.md b/docs/learn/conviction-staking-deep-dive.md deleted file mode 100644 index da3e7a93..00000000 --- a/docs/learn/conviction-staking-deep-dive.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: "Conviction Staking: Designing Trust into Bittensor" ---- - -# Conviction staking: designing trust into Bittensor - -Subnet ownership in Bittensor has a fundamental information problem. A subnet owner holds alpha staked to their own hotkey — but nothing prevents them from quietly reducing that position. An investor staking into a subnet where the owner has already reduced exposure to near zero is taking on risk they cannot see. - -This is sometimes called the rug-pull problem, though "silent exit" is more precise: the owner doesn't need to do anything dramatic, just unstake gradually and let their committed position shrink while the subnet continues operating and attracting external stake. - -Conviction staking addresses this by introducing a cryptographic metric for **commitment**, based on a new mechanism whereby stakers can **lock** their stake to a subnet. Once locked, stake must be unlocked (an on-chain operation which is therefore public information) before it can be unstaked. Unlocked becomes available to unstake all only gradually, with 50% availabe after ~30 days and 85% available at 60 days. - -Gradual unlock-and-release process gives investors a period to respond to planned exits by subnet owners or other major investors. - -## What conviction measures - -Locking stake creates a **conviction score** — a number that grows from zero toward the locked amount following an exponential curve: - -$$c_1 = m - (m - c_0) \cdot e^{-\Delta t / \tau}$$ - -where $m$ is the locked mass (alpha), $c_0$ is conviction at the last checkpoint, $\Delta t$ is elapsed blocks, and $\tau$ is 648,000 blocks (≈ 90 days). - -The intuition: conviction tracks the gap between current conviction and locked mass, and that gap shrinks exponentially. A fresh lock of 100α starts at zero conviction. After 90 days: ~63α. After 180 days: ~86α. It approaches 100α asymptotically — always getting closer, never quite arriving. - -This creates a **time cost** for conviction. You cannot lock stake today and claim full conviction tomorrow. A subnet owner claiming 90 days of high conviction has demonstrably been locked for 90 days — the chain records this and the formula is public. - -Conviction is also the foundation for a future **subnet governance** mechanism. The hotkey with the highest total conviction on a subnet (the "subnet king") is expected to gain voting or veto rights over subnet parameters as the protocol matures. Conviction therefore becomes the measure by which control of a subnet can shift — slowly, visibly, and through demonstrated long-term commitment rather than a sudden ownership transfer. - -## Two taus, two different roles - -The mechanism uses two exponential time constants, and understanding the difference between them clarifies the design. - -**Conviction tau (τ = 648,000 blocks ≈ 90 days)** governs how quickly conviction accumulates toward locked mass. This is a **design parameter** — the protocol designers chose a timescale they consider meaningful for long-term commitment. Ninety days is long enough that conviction cannot be manufactured overnight, but short enough that a genuine long-term holder builds substantial conviction within a quarter. - -**Unlock tau (τ = 216,000 blocks ≈ 30 days)** governs how quickly unlocked stake becomes withdrawable after `unlock_stake` is called. This plays a structurally different role. - -When an owner calls `unlock_stake`, observers now have three numbers: the unlocked amount, the unlock-rate constant (public, fixed), and the block of the unlock event. From those three, they can compute exactly when any fraction of that position becomes withdrawable: - -$$\text{withdrawable}(t) = \text{unlocked} \cdot (1 - e^{-\Delta t / \tau_{\text{unlock}}})$$ - -This is closer to what the perceptual psychologist David Lee called **tau** in a different sense entirely — a first-order variable in the observable stream that encodes a hidden but actionable quantity without requiring explicit knowledge of the underlying parameters. Lee showed that animals tracking an approaching object don't need to know its speed or distance; the ratio of image size to its rate of expansion gives time-to-contact directly. - -In conviction staking, a rational observer watching an unlock event doesn't need to know the owner's total position size or their intentions. The unlock amount, the rate constant, and the elapsed blocks give you "time until this stake can exit" — the variable you actually care about when deciding whether to stay in a subnet. The unlock period is precisely designed so that this information is available and actionable before the exit completes. - -In short: conviction tau is the designer's statement about what commitment means temporally. Unlock tau is the observer's tool for computing time-to-exit. - -## Subnet owners start locked by default - -One detail that changes the practical picture: subnet owners don't have to remember to lock their stake. When an owner receives their distribution cut each epoch, **it is automatically locked** to their hotkey. From the moment someone registers a subnet, their owner cut begins accumulating as locked alpha, and conviction begins growing from zero. - -Unlocking requires a conscious, explicit `unlock_stake` transaction. This flips the default: owners are locked until they choose otherwise, rather than unlocked until they choose to lock. An absence of lock state on a subnet owner's hotkey is therefore a meaningful signal — it means the owner has taken an active step to unlock, not simply that they never engaged with the feature. - -## The commitment ladder - -The combination of auto-locking, conviction growth, and unlock delay creates a natural commitment ladder that investors can read from chain state: - -| Signal | What it means | -|---|---| -| No lock on owner hotkey | Owner has explicitly unlocked, or is newly onboarded and unlock was intentional | -| Lock exists, conviction < 30% | Recent or recently topped-up lock; commitment is new | -| Lock exists, conviction > 63% | Owner has been continuously locked for at least one time constant (90 days) | -| `unlock_stake` event emitted | Owner has signaled intent to reduce position; exit begins now | -| High conviction + large locked mass | Demonstrated long-term commitment; costly to reverse quickly | - -Tools like [tao.app](https://www.tao.app) and tau.stats are building interfaces to surface this data per subnet. The mechanism only works as an investor signal if the data is visible — the protocol makes it available on-chain, but ecosystem tooling is what makes it legible at a glance. - -## A note on what conviction does not do - -Conviction is a governance and signaling mechanism. It does **not** affect emissions. Locking more stake, holding it longer, or achieving maximum conviction does not change how much alpha you earn — emissions continue to be determined by stake weight and consensus participation. - -This is intentional. Conflating conviction with emission weight would distort the incentive: owners would lock not because they believe in the subnet but because it increases their yield. Keeping them separate means conviction is a credible signal of belief — someone who locks alpha and waits 90 days is expressing long-term confidence in the subnet, not optimizing a reward formula. - -## Using this as a builder - -If you're building tooling that interacts with alpha transfers within a subnet, one implementation detail matters: locked and unlocking alpha travels with same-subnet stake transfers. The runtime applies a priority order — freely available alpha moves first, then unlocking alpha, then locked alpha — and a transfer that must draw from locked mass will fail if the destination coldkey's existing lock points to a different hotkey (`LockHotkeyMismatch`). - -For exchanges and wallets that accept alpha transfers: check lock state before accepting. Alpha that arrives locked cannot be immediately unstaked. An `unlock_stake` call followed by the 30-day decay period is required before that stake becomes liquid. - -Two runtime API calls are available for querying conviction state: - -- `get_hotkey_conviction(hotkey, netuid)` — total conviction for a hotkey on a subnet, summed across all locking coldkeys -- `get_most_convicted_hotkey_on_subnet(netuid)` — the current "subnet king" by conviction - -Conviction is a rolling value that changes every block — query at the current block for the current value, or apply the formula to project forward given the stored checkpoint. - ---- - -The full reference documentation, including extrinsic signatures, error types, storage layout, and the implementation appendix showing how the checkpoint system works in code, is in [Conviction Staking](../staking-and-delegation/conviction-staking). diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md index 441a51c5..af3bd877 100644 --- a/docs/staking-and-delegation/conviction-staking.md +++ b/docs/staking-and-delegation/conviction-staking.md @@ -1,22 +1,20 @@ --- -title: "Conviction Staking (Stake Locks)" +title: "Conviction and locked stake" --- -# Conviction staking (stake locks) +# Conviction and locked stake -Conviction staking lets coldkey holders lock alpha stake to a specific hotkey on a subnet. Locked stake builds **conviction** — a score that grows over time toward the locked amount — providing a public, on-chain signal of long-term commitment that cannot be silently reversed. +The locked stake features lets coldkey holders lock alpha stake to a specific hotkey on a subnet. Locked stake builds **conviction** — a score that grows over time toward the locked amount — providing a public, on-chain signal of long-term commitment that cannot be silently reversed. -The immediate use case is investor confidence in subnet owners. A subnet owner whose alpha is locked has made a cryptographic commitment: unwinding a large position requires calling `unlock_stake` and then waiting through an exponential decay period before the stake can be withdrawn. This gives other stakers advance warning before any large exit completes. - -For a deeper look, see [Conviction Staking: Designing Trust into Bittensor](../learn/conviction-staking-deep-dive). +Conviction provides information about subnet owners and other large investors in a subnet. A subnet owner whose alpha is locked has made a cryptographic commitment: unwinding a large position requires calling `unlock_stake` and then waiting through an exponential decay period before the stake can be withdrawn. This gives other stakers advance warning before any large exit completes. :::note Testnet launch Conviction staking is live on testnet (spec version 403) as of May 2026 and is tentatively scheduled for mainnet on May 13, 2026. ::: -## How locks work +## The stake lock mechanism -A lock binds a specific **amount** of a coldkey's alpha on a subnet to a specific **hotkey**. The lock enforces one invariant: +Locking stake binds a specific amount of a coldkey's staked alpha, on a subnet to a specific delegate (stake recipient) hotkey. The lock enforces one invariant: > **Total alpha staked by the coldkey on that subnet ≥ locked amount** @@ -28,7 +26,9 @@ One lock per coldkey per subnet is enforced. If a lock already exists for a cold ## Conviction -Conviction is a score that grows from zero toward the locked amount following an exponential curve: +The conviction score grows over time, from zero toward the locked amount. It therefore provides a combined signal of how long *and* how much a staker had invested in the subnet. + +Growth follow an exponential curve: $$c_1 = m - (m - c_0) \cdot e^{-\Delta t / \tau}$$ @@ -194,7 +194,7 @@ Two runtime API calls expose conviction state on-chain: Conviction is a rolling value — querying at different blocks yields different results as time passes and the exponential grows. -Tools like [tao.app](https://www.tao.app) and tau.stats are expected to surface per-subnet lock state, including subnet owner lock percentage and conviction scores, providing investors with at-a-glance commitment signals. +Tools like [tao.app](https://www.tao.app) and [taostats.io](https://taostats.io/) are expected to surface per-subnet lock state, including subnet owner lock percentage and conviction scores, providing investors with at-a-glance commitment signals. ## Storage diff --git a/sidebars.js b/sidebars.js index 1d2ba6bc..1b56c0cc 100644 --- a/sidebars.js +++ b/sidebars.js @@ -54,8 +54,7 @@ const sidebars = { "learn/emissions", "learn/ema", "learn/yuma-consensus", - "learn/yc3-blog", - "learn/conviction-staking-deep-dive", + "learn/yc3-blog", "concepts/weight-copying-in-bittensor", "learn/yuma3-migration-guide", "learn/fees", From f18eb8e98b6233987afb7ff6c5fd1c542996cbee Mon Sep 17 00:00:00 2001 From: michael trestman Date: Mon, 11 May 2026 06:40:16 -0700 Subject: [PATCH 06/10] wip --- docs/staking-and-delegation/conviction-staking.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md index af3bd877..f2f492e7 100644 --- a/docs/staking-and-delegation/conviction-staking.md +++ b/docs/staking-and-delegation/conviction-staking.md @@ -39,14 +39,19 @@ where: - $\Delta t$ — blocks elapsed since last update - $\tau$ — maturity time constant: **648,000 blocks (≈ 90 days)** -Conviction is computed lazily — the locked mass does not change, only the evaluation time advances. No periodic transactions are required to keep conviction growing. -![Conviction growth and unlock availability, side by side](/img/conviction-panels.svg) +## Dynamics locking and unlocking + +When someone locks stake, their conviction increases over time, up to the locked amount. When someone unlocks stake, their available unlocked stake increases over time up to the amount the just unlocked. + +The same formula governs both curves, only the time constant differs. The lifecycle graph below shows how they interact in sequence: -_Left — Conviction growth: `f(t) = 1 − exp(−t / τ)`, τ = 648,000 blocks ≈ 90 days. Dot marks one time constant (63.2% of max)._ -_Right — Unlock availability: `f(t) = 1 − exp(−t / τ)`, τ = 216,000 blocks ≈ 30 days. Dot marks one time constant (63.2% of unlocked amount available). Both x-axes span 3τ._ +**Conviction growth**: `f(t) = 1 − exp(−t / τ)`, τ = 648,000 blocks ≈ 90 days. Dot marks one time constant (63.2% of max). +**Unlock availability**: `f(t) = 1 − exp(−t / τ)`, τ = 216,000 blocks ≈ 30 days. Dot marks one time constant (63.2% of unlocked amount available). Both x-axes span 3τ. + + +![Conviction growth and unlock availability, side by side](/img/conviction-panels.svg) -The same formula governs both curves — only the time constant differs. The lifecycle graph below shows how they interact in sequence: ![Conviction lifecycle: lock then unlock](/img/conviction-lifecycle.svg) From 8993d847a27663ea56be56e1d08a7715b38e498e Mon Sep 17 00:00:00 2001 From: michael trestman Date: Mon, 11 May 2026 06:44:36 -0700 Subject: [PATCH 07/10] wip --- docs/staking-and-delegation/conviction-staking.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md index f2f492e7..cf085bd7 100644 --- a/docs/staking-and-delegation/conviction-staking.md +++ b/docs/staking-and-delegation/conviction-staking.md @@ -57,16 +57,19 @@ The same formula governs both curves, only the time constant differs. The lifecy _Scenario: lock 100α at day 0; call `unlock_stake(50α)` at day 90. Conviction (blue) drops instantly by the unlocked amount and then rebuilds toward the new lower ceiling. Unlocked α (orange) becomes gradually withdrawable over the following ~30 days._ -**The core idea: conviction chases the locked amount, and the gap shrinks exponentially.** -Rewrite the equation as: +To understand the design intent, notice that the math is about *closing a gap*, either gap between locked amount and eventual conviction, or unlocked amount and available amount. + +If we re-arrange the equation to focus on the gap: ``` gap = m - c0 (distance between current conviction and max) c1 = m - gap × exp(-dt/τ) ``` -`exp(-dt/τ)` is a number between 0 and 1 — it's the fraction of the gap that *survives* after `dt` blocks. So: +`exp(-dt/τ)` is a number between 0 and 1 — it's the fraction of the gap that remains after `dt` blocks. + +So: - `dt = 0` → `exp(0) = 1` → gap unchanged → c1 = c0 ✓ - `dt = τ` (90 days) → `exp(-1) ≈ 0.368` → 36.8% of the gap remains → you've closed ~63% of it @@ -80,7 +83,7 @@ at 90 days: c1 = 100 - 100 × 0.368 = 63.2 at 180 days: c1 = 100 - 100 × 0.135 = 86.5 ``` -Conviction is always chasing `m` — getting closer every block, never quite arriving. +Conviction is always closing in on `m`, getting closer every block, never quite arriving. **Example:** Lock 100 alpha at block 0 with no prior lock. From 1f0d3e0216a760f38d608215c435b88378471dd2 Mon Sep 17 00:00:00 2001 From: michael trestman Date: Mon, 11 May 2026 06:50:03 -0700 Subject: [PATCH 08/10] wip --- .../conviction-staking.md | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md index cf085bd7..41fc0516 100644 --- a/docs/staking-and-delegation/conviction-staking.md +++ b/docs/staking-and-delegation/conviction-staking.md @@ -4,7 +4,7 @@ title: "Conviction and locked stake" # Conviction and locked stake -The locked stake features lets coldkey holders lock alpha stake to a specific hotkey on a subnet. Locked stake builds **conviction** — a score that grows over time toward the locked amount — providing a public, on-chain signal of long-term commitment that cannot be silently reversed. +The locked stake features lets coldkey holders lock alpha stake to a specific hotkey on a subnet. Locked stake builds **conviction**, a score that grows over time toward the locked amount. Conviction provides a public, on-chain signal of long-term commitment that cannot be silently reversed. Conviction provides information about subnet owners and other large investors in a subnet. A subnet owner whose alpha is locked has made a cryptographic commitment: unwinding a large position requires calling `unlock_stake` and then waiting through an exponential decay period before the stake can be withdrawn. This gives other stakers advance warning before any large exit completes. @@ -18,7 +18,7 @@ Locking stake binds a specific amount of a coldkey's staked alpha, on a subnet t > **Total alpha staked by the coldkey on that subnet ≥ locked amount** -Everything above the locked amount is freely unstakable. The coldkey can also continue to stake additional alpha at any time — the lock only blocks the staked balance from dropping below the locked amount. +Everything above the locked amount is freely unstakable. The coldkey can also continue to stake additional alpha at any time: the lock only blocks the staked balance from dropping below the locked amount. Locks are **indefinite**: they persist until the coldkey explicitly calls `unlock_stake`. There is no expiry and no need to periodically renew a lock. @@ -33,11 +33,11 @@ Growth follow an exponential curve: $$c_1 = m - (m - c_0) \cdot e^{-\Delta t / \tau}$$ where: -- $c_0$ — conviction at last update -- $c_1$ — conviction now -- $m$ — locked mass (alpha units) -- $\Delta t$ — blocks elapsed since last update -- $\tau$ — maturity time constant: **648,000 blocks (≈ 90 days)** +- $c_0$: conviction at last update +- $c_1$: conviction now +- $m$: locked mass (alpha units) +- $\Delta t$: blocks elapsed since last update +- $\tau$: maturity time constant: **648,000 blocks (≈ 90 days)** ## Dynamics locking and unlocking @@ -67,7 +67,7 @@ gap = m - c0 (distance between current conviction and max) c1 = m - gap × exp(-dt/τ) ``` -`exp(-dt/τ)` is a number between 0 and 1 — it's the fraction of the gap that remains after `dt` blocks. +`exp(-dt/τ)` is a number between 0 and 1, the fraction of the gap that remains after `dt` blocks. So: @@ -109,13 +109,13 @@ api.tx.subtensorModule.lockStake(hotkey, netuid, amount) Locks `amount` alpha from the coldkey's stake on `netuid` to `hotkey`. - If no lock exists for this coldkey on `netuid`, a new lock is created with conviction 0. -- If a lock already exists, `amount` is added to the locked mass. The hotkey must match the existing lock — use `move_lock` first if switching hotkeys. +- If a lock already exists, `amount` is added to the locked mass. The hotkey must match the existing lock. Use `move_lock` first if switching hotkeys. - `amount` must not exceed the coldkey's available (unlocked) alpha on the subnet. **Errors:** -- `InsufficientStakeForLock` — available alpha is less than `amount` -- `LockHotkeyMismatch` — a lock exists for a different hotkey on this subnet -- `AmountTooLow` — amount is zero +- `InsufficientStakeForLock`: available alpha is less than `amount` +- `LockHotkeyMismatch`: a lock exists for a different hotkey on this subnet +- `AmountTooLow`: amount is zero **Event emitted:** `StakeLocked { coldkey, hotkey, netuid, amount }` @@ -129,10 +129,10 @@ Begins the process of unlocking `amount` alpha from the coldkey's existing lock - Immediately reduces locked mass by `amount` and conviction by `amount`. - The unlocked amount enters an exponential decay period. It becomes gradually withdrawable over time with a time constant of **216,000 blocks (≈ 30 days)**: roughly half is available after 30 days, ~86% after 60 days, and so on. -- While stake is in the unlocking period, it **cannot be unstaked or re-locked** — the available stake formula excludes both locked and unlocking amounts. +- While stake is in the unlocking period, it **cannot be unstaked or re-locked**. The available stake formula excludes both locked and unlocking amounts. **Errors:** -- `UnlockAmountTooHigh` — amount exceeds current locked mass +- `UnlockAmountTooHigh`: amount exceeds current locked mass **Event emitted:** `StakeUnlocked { coldkey, hotkey, netuid, amount }` @@ -155,7 +155,7 @@ Reassigns the coldkey's existing lock on `netuid` from its current hotkey to `de This gives the previous hotkey's stakers a window to react before conviction rebuilds on the new hotkey. **Errors:** -- `NoExistingLock` — no lock exists for this coldkey on the subnet +- `NoExistingLock`: no lock exists for this coldkey on the subnet **Event emitted:** `LockMoved { coldkey, origin_hotkey, destination_hotkey, netuid }` @@ -173,23 +173,18 @@ This means subnet owners start accumulating locked alpha and conviction from the **Hotkey swap (system-level):** When a hotkey is swapped via `btcli wallet swap-hotkey`, all locks targeting the old hotkey are transferred to the new hotkey. Conviction is **not** reset, because the same coldkey owns both hotkeys. -**Coldkey swap:** A coldkey swap fails if the destination coldkey already has **active locked mass** on any subnet. The swap succeeds if the destination coldkey only has expired or zero-mass locks — those are consolidated with the source coldkey's locks. +**Coldkey swap:** A coldkey swap fails if the destination coldkey already has **active locked mass** on any subnet. The swap succeeds if the destination coldkey only has expired or zero-mass locks. ## Transferring locked stake When stake is moved to another coldkey **within the same subnet**, lock obligations follow the alpha proportionally. The runtime resolves how much of the transfer carries lock state: -1. **Freely available alpha transfers first** — alpha above the locked + unlocking amount moves with no lock implications. -2. **Unlocking alpha is drawn next** — if the transfer exceeds freely available alpha, the shortfall comes from the source's unlocking mass. That amount arrives at the destination still in its decay period. -3. **Locked alpha is drawn last** — if the transfer still exceeds what's available, the remainder comes from locked mass. Conviction transfers proportionally. This step **fails with `LockHotkeyMismatch`** if the destination coldkey already has a lock pointing at a different hotkey. +1. **Freely available alpha transfers first**: alpha above the locked + unlocking amount moves with no lock implications. +2. **Unlocking alpha is drawn next**: if the transfer exceeds freely available alpha, the shortfall comes from the source's unlocking mass. That amount arrives at the destination still in its decay period. +3. **Locked alpha is drawn last**: if the transfer still exceeds what's available, the remainder comes from locked mass. Conviction transfers proportionally. This step **fails with `LockHotkeyMismatch`** if the destination coldkey already has a lock pointing at a different hotkey. **Cross-subnet moves are different**: moving stake between subnets goes through unstake → TAO transfer → restake, which must satisfy `ensure_available_stake`. You cannot drag locked or unlocking alpha across subnets. -**OTC use case**: a subnet owner with all their alpha locked can transfer some of it to an investor within the same subnet. Because available alpha is zero, the transferred amount comes entirely from locked mass — the investor receives it locked, pointing at the same hotkey, and must wait through the unlock period before they can unstake. - -:::warning For exchanges and tools accepting alpha transfers -If your system accepts same-subnet alpha transfers, check whether the incoming stake carries a lock. Locked alpha cannot be unstaked immediately — an unlock transaction and the subsequent decay period are required first. -::: ## Querying conviction @@ -202,14 +197,14 @@ Two runtime API calls expose conviction state on-chain: Conviction is a rolling value — querying at different blocks yields different results as time passes and the exponential grows. -Tools like [tao.app](https://www.tao.app) and [taostats.io](https://taostats.io/) are expected to surface per-subnet lock state, including subnet owner lock percentage and conviction scores, providing investors with at-a-glance commitment signals. +Explorer tools like [tao.app](https://www.tao.app) and [taostats.io](https://taostats.io/) are expected to surface per-subnet lock state, including subnet owner lock percentage and conviction scores, providing investors with at-a-glance commitment signals. ## Storage Lock state is stored in two maps: -- `Lock[(coldkey, netuid, hotkey)]` — per-coldkey lock record containing locked mass, unlocking mass, conviction score, and last update block -- `HotkeyLock[(netuid, hotkey)]` — aggregate lock totals per hotkey (used for conviction queries without iterating all coldkeys) +- `Lock[(coldkey, netuid, hotkey)]`: per-coldkey lock record containing locked mass, unlocking mass, conviction score, and last update block +- `HotkeyLock[(netuid, hotkey)]`: aggregate lock totals per hotkey (used for conviction queries without iterating all coldkeys) The maturity time constant (`MaturityRate`) and unlock time constant (`UnlockRate`) are configurable runtime storage values, defaulting to 648,000 and 216,000 blocks respectively. These values can be adjusted by governance — the unlock and maturity windows are key parameters in the mechanism's attack surface, and tuning them changes how quickly conviction can build or unwind. From dbb6360d44eb2930bb6ff3a06b5602cafea4bcf2 Mon Sep 17 00:00:00 2001 From: michael trestman Date: Mon, 11 May 2026 06:50:54 -0700 Subject: [PATCH 09/10] wip --- docs/staking-and-delegation/conviction-staking.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md index 41fc0516..8fd1ee95 100644 --- a/docs/staking-and-delegation/conviction-staking.md +++ b/docs/staking-and-delegation/conviction-staking.md @@ -195,7 +195,7 @@ Two runtime API calls expose conviction state on-chain: | `get_hotkey_conviction(hotkey, netuid)` | Current total conviction for `hotkey` on `netuid`, summed over all coldkeys that have locked to it | | `get_most_convicted_hotkey_on_subnet(netuid)` | The hotkey with the highest conviction on `netuid`, or `None` if no locks exist | -Conviction is a rolling value — querying at different blocks yields different results as time passes and the exponential grows. +Conviction is a rolling value, so querying at different blocks yields different results as time passes and the exponential grows. Explorer tools like [tao.app](https://www.tao.app) and [taostats.io](https://taostats.io/) are expected to surface per-subnet lock state, including subnet owner lock percentage and conviction scores, providing investors with at-a-glance commitment signals. @@ -206,11 +206,11 @@ Lock state is stored in two maps: - `Lock[(coldkey, netuid, hotkey)]`: per-coldkey lock record containing locked mass, unlocking mass, conviction score, and last update block - `HotkeyLock[(netuid, hotkey)]`: aggregate lock totals per hotkey (used for conviction queries without iterating all coldkeys) -The maturity time constant (`MaturityRate`) and unlock time constant (`UnlockRate`) are configurable runtime storage values, defaulting to 648,000 and 216,000 blocks respectively. These values can be adjusted by governance — the unlock and maturity windows are key parameters in the mechanism's attack surface, and tuning them changes how quickly conviction can build or unwind. +The maturity time constant (`MaturityRate`) and unlock time constant (`UnlockRate`) are configurable runtime storage values, defaulting to 648,000 and 216,000 blocks respectively. These values can be adjusted by governance. The unlock and maturity windows are key parameters in the mechanism's attack surface, and tuning them changes how quickly conviction can build or unwind. -## Appendix: implementation — lazy evaluation and checkpointing +## Appendix: implementation -The conviction formula is closed-form — no iteration, no history — because the runtime stores only a checkpoint at the last mutation and evaluates forward on demand. +The conviction formula is closed-form with no iteration or history, because the runtime stores only a checkpoint at the last mutation and evaluates forward on demand. **What's stored** (`LockState`, `lib.rs`): @@ -270,7 +270,7 @@ Self::insert_lock_state(coldkey, netuid, hotkey, LockState { }); ``` -Every mutation — `lock_stake`, `unlock_stake`, `move_lock` — calls `roll_forward_lock` first. This advances conviction to the current block and writes it as the new `c0`. From that point, the stored `(c0, m, last_update)` triple is sufficient to evaluate conviction at any future block without needing history. +Every mutation (`lock_stake`, `unlock_stake`, `move_lock`) calls `roll_forward_lock` first. This advances conviction to the current block and writes it as the new `c0`. From that point, the stored `(c0, m, last_update)` triple is sufficient to evaluate conviction at any future block without needing history. Conviction is therefore a pure function of elapsed time between mutations. Given the stored checkpoint, conviction at any future block `b` is: From d3fa09139430cdad383ea83dc786252124b41f4b Mon Sep 17 00:00:00 2001 From: Dera Okeke Date: Mon, 11 May 2026 19:49:23 +0100 Subject: [PATCH 10/10] added dropdown --- .../conviction-staking.md | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/docs/staking-and-delegation/conviction-staking.md b/docs/staking-and-delegation/conviction-staking.md index 8fd1ee95..034331b9 100644 --- a/docs/staking-and-delegation/conviction-staking.md +++ b/docs/staking-and-delegation/conviction-staking.md @@ -26,20 +26,20 @@ One lock per coldkey per subnet is enforced. If a lock already exists for a cold ## Conviction -The conviction score grows over time, from zero toward the locked amount. It therefore provides a combined signal of how long *and* how much a staker had invested in the subnet. +The conviction score grows over time, from zero toward the locked amount. It therefore provides a combined signal of how long _and_ how much a staker had invested in the subnet. Growth follow an exponential curve: $$c_1 = m - (m - c_0) \cdot e^{-\Delta t / \tau}$$ where: + - $c_0$: conviction at last update - $c_1$: conviction now - $m$: locked mass (alpha units) - $\Delta t$: blocks elapsed since last update - $\tau$: maturity time constant: **648,000 blocks (≈ 90 days)** - ## Dynamics locking and unlocking When someone locks stake, their conviction increases over time, up to the locked amount. When someone unlocks stake, their available unlocked stake increases over time up to the amount the just unlocked. @@ -49,16 +49,16 @@ The same formula governs both curves, only the time constant differs. The lifecy **Conviction growth**: `f(t) = 1 − exp(−t / τ)`, τ = 648,000 blocks ≈ 90 days. Dot marks one time constant (63.2% of max). **Unlock availability**: `f(t) = 1 − exp(−t / τ)`, τ = 216,000 blocks ≈ 30 days. Dot marks one time constant (63.2% of unlocked amount available). Both x-axes span 3τ. - ![Conviction growth and unlock availability, side by side](/img/conviction-panels.svg) - ![Conviction lifecycle: lock then unlock](/img/conviction-lifecycle.svg) -_Scenario: lock 100α at day 0; call `unlock_stake(50α)` at day 90. Conviction (blue) drops instantly by the unlocked amount and then rebuilds toward the new lower ceiling. Unlocked α (orange) becomes gradually withdrawable over the following ~30 days._ +
+ See how it's calculated! +_Scenario: lock 100α at day 0; call `unlock_stake(50α)` at day 90. Conviction (blue) drops instantly by the unlocked amount and then rebuilds toward the new lower ceiling. Unlocked α (orange) becomes gradually withdrawable over the following ~30 days._ -To understand the design intent, notice that the math is about *closing a gap*, either gap between locked amount and eventual conviction, or unlocked amount and available amount. +To understand the design intent, notice that the math is about _closing a gap_, either gap between locked amount and eventual conviction, or unlocked amount and available amount. If we re-arrange the equation to focus on the gap: @@ -88,16 +88,18 @@ Conviction is always closing in on `m`, getting closer every block, never quite **Example:** Lock 100 alpha at block 0 with no prior lock. | Elapsed time | Conviction | -|---|---| -| 0 days | 0 | -| 30 days | ≈ 28.3 | -| 62 days | ≈ 50.0 | -| 90 days | ≈ 63.2 | -| 180 days | ≈ 86.5 | -| 270 days | ≈ 95.0 | +| ------------ | ---------- | +| 0 days | 0 | +| 30 days | ≈ 28.3 | +| 62 days | ≈ 50.0 | +| 90 days | ≈ 63.2 | +| 180 days | ≈ 86.5 | +| 270 days | ≈ 95.0 | Maximum conviction equals the locked mass. Topping up an existing lock adds to locked mass immediately; conviction continues growing from its current value toward the new (higher) maximum. +
+ ## Extrinsics ### `lock_stake` @@ -113,6 +115,7 @@ Locks `amount` alpha from the coldkey's stake on `netuid` to `hotkey`. - `amount` must not exceed the coldkey's available (unlocked) alpha on the subnet. **Errors:** + - `InsufficientStakeForLock`: available alpha is less than `amount` - `LockHotkeyMismatch`: a lock exists for a different hotkey on this subnet - `AmountTooLow`: amount is zero @@ -132,6 +135,7 @@ Begins the process of unlocking `amount` alpha from the coldkey's existing lock - While stake is in the unlocking period, it **cannot be unstaked or re-locked**. The available stake formula excludes both locked and unlocking amounts. **Errors:** + - `UnlockAmountTooHigh`: amount exceeds current locked mass **Event emitted:** `StakeUnlocked { coldkey, hotkey, netuid, amount }` @@ -155,6 +159,7 @@ Reassigns the coldkey's existing lock on `netuid` from its current hotkey to `de This gives the previous hotkey's stakers a window to react before conviction rebuilds on the new hotkey. **Errors:** + - `NoExistingLock`: no lock exists for this coldkey on the subnet **Event emitted:** `LockMoved { coldkey, origin_hotkey, destination_hotkey, netuid }` @@ -185,15 +190,14 @@ When stake is moved to another coldkey **within the same subnet**, lock obligati **Cross-subnet moves are different**: moving stake between subnets goes through unstake → TAO transfer → restake, which must satisfy `ensure_available_stake`. You cannot drag locked or unlocking alpha across subnets. - ## Querying conviction Two runtime API calls expose conviction state on-chain: -| Method | Returns | -|---|---| -| `get_hotkey_conviction(hotkey, netuid)` | Current total conviction for `hotkey` on `netuid`, summed over all coldkeys that have locked to it | -| `get_most_convicted_hotkey_on_subnet(netuid)` | The hotkey with the highest conviction on `netuid`, or `None` if no locks exist | +| Method | Returns | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `get_hotkey_conviction(hotkey, netuid)` | Current total conviction for `hotkey` on `netuid`, summed over all coldkeys that have locked to it | +| `get_most_convicted_hotkey_on_subnet(netuid)` | The hotkey with the highest conviction on `netuid`, or `None` if no locks exist | Conviction is a rolling value, so querying at different blocks yields different results as time passes and the exponential grows.