Skip to content

Conviction updates#2687

Merged
sam0x17 merged 10 commits into
devnet-readyfrom
feat/conviction-v2
May 25, 2026
Merged

Conviction updates#2687
sam0x17 merged 10 commits into
devnet-readyfrom
feat/conviction-v2

Conversation

@gztensor
Copy link
Copy Markdown
Contributor

@gztensor gztensor commented May 21, 2026

Description

Functional changes

  1. Everyone's conviction to subnet owner's hotkey is immediate
  • Use new set of conviction aggregates
  • Remove deprecated conviction maps
  1. Default behavior of lock is decaying for all locking coldkeys
  2. Unlock rate and conviction maturity rate are both set to 311622 (50% in 1 month)
  3. Owner's cut auto-lock can be disabled by the subnet owner
  4. An RPC for reading coldkey lock (rolls forward): get_coldkey_lock

Refactoring

The conviction maturing and unlock logic as well as aggregate tracking is incapsulated in struct ConvictionModel.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Other (please describe): State and code cleanup

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have run ./scripts/fix_rust.sh to ensure my code is formatted and linted correctly
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@gztensor gztensor changed the title Everyone's comvinction to subnet owners is immediate Everyone's convinction to subnet owners is immediate May 21, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

.map(|lock| Self::roll_forward_owner_lock(netuid, lock, now).conviction)
.unwrap_or_else(|| U64F64::saturating_from_num(0)),
)
let owner_conviction = OwnerLock::<T>::get(netuid)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] OwnerLock storage is reinterpreted without migrating existing aggregates

OwnerLock previously represented aggregate locks made by the subnet owner coldkey, regardless of which hotkey was locked to and whether that coldkey was in decaying mode. This PR now credits any existing OwnerLock directly to SubnetOwnerHotkey and rolls it as (owner_lock=true, perpetual_lock=true), while the new DecayingOwnerLock starts empty. Without an on-chain migration that rebuilds OwnerLock, DecayingOwnerLock, HotkeyLock, and DecayingHotkeyLock from individual Lock entries under the new (hotkey == SubnetOwnerHotkey, is_perpetual) rules, legacy owner-coldkey locks to non-owner hotkeys or decaying owner locks are misattributed to the current owner hotkey. That corrupts hotkey_conviction, get_total_conviction, and subnet_king, and can incorrectly preserve or change subnet ownership. Add a storage-versioned migration or keep backward-compatible interpretation until migration completes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be valid if we were preserving existing conviction state. We are intentionally not preserving it on testnet. The final migration clears all current conviction lock maps, including OwnerLock, DecayingOwnerLock, HotkeyLock, DecayingHotkeyLock, individual Lock, and DecayingLock, before the new interpretation can operate on persisted values. Since mainnet has not had this state live, no rebuild migration is needed.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

🛡️ AI Review — Skeptic (security review)

VERDICT: SAFE

BASELINE scrutiny: established write-permission opentensor contributor; no Gittensor allowlist hit found; branch feat/conviction-v2 -> devnet-ready is valid.

No .github, dependency, lockfile, or build-script changes are present. Static review of the runtime conviction aggregation changes, owner-cut auto-lock toggle, conviction-lock reset migration, RPC addition, and spec-version bump did not find malicious code or a security vulnerability.

Findings

No findings.

Prior-comment reconciliation

  • 473b9654: addressed — The current diff still includes migrate_reset_tnet_conviction_locks, clearing Lock, HotkeyLock, DecayingHotkeyLock, OwnerLock, DecayingOwnerLock, and DecayingLock without decoding existing values, so old OwnerLock bytes are not reinterpreted under the new owner-hotkey aggregate semantics.

Conclusion

No malicious code or security vulnerability found in the current diff. The prior OwnerLock transition concern remains addressed by the migration that clears old conviction lock maps before the revised owner-hotkey aggregate semantics are used.


🔍 AI Review — Auditor (domain review)

VERDICT: 👎

Gittensor: LIKELY; gztensor has write permission and substantial recent subtensor PR history, but no trusted allowlist/on-chain match.

PR body is substantive and matches the implementation. spec_version is bumped to 409; I did not apply auto-fixes.

Duplicate-work check: overlapping open PRs share common runtime/pallet files or deployment surface, but I did not find a better duplicate candidate for this conviction owner-hotkey update.

I did not run tests because both blockers are static bounded-execution issues carried forward from the prior Auditor pass.

Findings

Sev File Finding
HIGH pallets/subtensor/src/migrations/migrate_reset_tnet_conviction_locks.rs:31 Migration clears user-sized lock storage in one upgrade block inline
HIGH pallets/subtensor/src/staking/lock.rs:1421 Owner hotkey swaps can scan the entire lock table inline

Prior-comment reconciliation

  • 6044b39c: not addressed — The migration still uses clear(u32::MAX, None) on user-sized conviction lock maps in one runtime-upgrade pass.
  • 31de9aba: not addressedswap_hotkey_locks still falls back to Lock::<T>::iter() and scans the whole lock table when owner/hotkey lock movement is needed.

Conclusion

Blocking on the same bounded-execution issues from the prior Auditor pass. The conviction behavior has focused tests, but runtime upgrade and hotkey-swap paths must be bounded before merge.


📜 Previous run (superseded)
Sev File Finding Status
HIGH pallets/subtensor/src/migrations/migrate_reset_tnet_conviction_locks.rs:31 Migration clears user-sized lock storage in one upgrade block ➡️ Carried forward to current findings
The migration still uses clear(u32::MAX, None) on user-sized conviction lock maps in one runtime-upgrade pass.
HIGH pallets/subtensor/src/staking/lock.rs:1421 Owner hotkey swaps can scan the entire lock table ➡️ Carried forward to current findings
swap_hotkey_locks still falls back to Lock::<T>::iter() and scans the whole lock table when owner/hotkey lock movement is needed.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

.map(|lock| Self::roll_forward_owner_lock(netuid, lock, now).conviction)
.unwrap_or_else(|| U64F64::saturating_from_num(0)),
)
let owner_conviction = OwnerLock::<T>::get(netuid)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] OwnerLock storage is reinterpreted without migrating existing aggregates

OwnerLock already exists on-chain with the old meaning: aggregate locks made by the subnet owner coldkey. This PR changes the runtime to treat the same storage item as aggregate locks to the subnet owner hotkey, and this line credits that legacy value to SubnetOwnerHotkey. Existing OwnerLock entries can therefore be assigned to the wrong hotkey, and prior decaying owner-coldkey aggregates can also be rolled as perpetual owner-hotkey conviction. That can corrupt hotkey_conviction, subnet_king, and subsequent owner reassignment after the runtime upgrade.

This needs a storage migration that rebuilds owner-hotkey aggregate buckets from the individual Lock records, splitting perpetual vs decaying entries into OwnerLock and DecayingOwnerLock, or otherwise clears/recomputes the legacy OwnerLock before these new reads are live.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.


// This only affects testnet: mainnet has not had this conviction lock state
// deployed with live values yet.
let lock_removal = Lock::<T>::clear(u32::MAX, None);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Migration clears user-sized lock storage in one upgrade block

Lock, HotkeyLock, DecayingHotkeyLock, OwnerLock, DecayingOwnerLock, and DecayingLock are cleared with u32::MAX in a single on_runtime_upgrade migration. These maps can scale with user lock activity, and computing the weight after clear() does not bound the work already performed. If testnet/devnet has more entries than expected, the upgrade can exceed block limits or stall. Make this bounded with finite clear limits plus a cursor/progress flag, or otherwise gate/prove the exact live state size before running it.


// Gather locks for old hotkey (only if hotkey locks exist, otherwise skip to save reads)
if !hotkey_locks_to_transfer.is_empty() || !decaying_hotkey_locks_to_transfer.is_empty() {
if !netuids_to_transfer.is_empty() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Owner hotkey swaps can scan the entire lock table

With old_is_owner_hotkey || new_is_owner_hotkey feeding netuids_to_transfer, any swap involving a subnet owner hotkey enters Lock::<T>::iter() and scans every lock across all coldkeys/subnets. This is reachable from signed swap_hotkey / swap_hotkey_v2, while the call has a fixed pre-dispatch weight. Returning dynamic post-dispatch weight after the scan does not bound block execution. Use a bounded cursor, maintain a reverse index by hotkey, or disallow this path from user extrinsics and handle owner-hotkey aggregate repair through a bounded migration.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

@github-actions github-actions Bot mentioned this pull request May 25, 2026
13 tasks
@gztensor gztensor changed the title Everyone's convinction to subnet owners is immediate Conviction updates May 25, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.


// This only affects testnet: mainnet has not had this conviction lock state
// deployed with live values yet.
let lock_removal = Lock::<T>::clear(u32::MAX, None);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Migration clears user-sized lock storage in one upgrade block

This migration calls clear(u32::MAX, None) over Lock and then repeats the same pattern for every aggregate lock map. These maps are user-sized, so a populated testnet/devnet can force the runtime upgrade to delete an unbounded number of keys in one on_runtime_upgrade. Returning the measured weight after the work does not make the upgrade bounded; the block still has to execute all deletions before it can finish. Please convert this to a bounded migration with a cursor and per-block limit, or gate it behind a provable small upper bound for the target network state.

// Gather locks for old hotkey (only if hotkey locks exist, otherwise skip to save reads)
if !hotkey_locks_to_transfer.is_empty() || !decaying_hotkey_locks_to_transfer.is_empty() {
if !netuids_to_transfer.is_empty() {
for ((coldkey, netuid, hotkey), lock) in Lock::<T>::iter() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Owner hotkey swaps can scan the entire lock table

When any relevant netuid is found, swap_hotkey_locks iterates Lock::<T>::iter() and filters by hotkey == old_hotkey. Lock is keyed by (coldkey, netuid, hotkey), so this is a full-table scan over all coldkey lock entries, not just the old hotkey's locks. This runs inside user-callable hotkey swap flow and is not reflected by the fixed extrinsic weight, so a large lock table can make hotkey swaps exceed block limits. The lock index needs to support bounded prefix iteration for the old hotkey, or this path needs a bounded/chunked design.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lock cardinality is bounded by the existing stake-position cardinality, and hotkey swap already contains unbounded/prefix iteration over alpha stake positions, so this does not introduce a fundamentally new unbounded storage class.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

@gztensor gztensor added the skip-cargo-audit This PR fails cargo audit but needs to be merged anyway label May 25, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.


// This only affects testnet: mainnet has not had this conviction lock state
// deployed with live values yet.
let lock_removal = Lock::<T>::clear(u32::MAX, None);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Migration clears user-sized lock storage in one upgrade block

This migration calls clear(u32::MAX, None) on Lock and then repeats the same unbounded clear for five more conviction maps. These maps are user-sized, so a runtime upgrade can spend an unbounded amount of work in one on_runtime_upgrade pass before the chain can produce the next block. The returned weight accounts for work after it has already happened; it does not bound execution. Make this migration bounded, e.g. with stored cursors and limited per-block cleanup, or prove from live state that these prefixes are bounded to a safe maximum before shipping the upgrade.

// Gather locks for old hotkey (only if hotkey locks exist, otherwise skip to save reads)
if !hotkey_locks_to_transfer.is_empty() || !decaying_hotkey_locks_to_transfer.is_empty() {
if !netuids_to_transfer.is_empty() {
for ((coldkey, netuid, hotkey), lock) in Lock::<T>::iter() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Owner hotkey swaps can scan the entire lock table

When netuids_to_transfer is non-empty, swap_hotkey_locks falls back to Lock::<T>::iter() and scans every lock across all coldkeys and subnets to find entries for old_hotkey. This is reachable from the user-facing all-subnet hotkey swap path, so a single extrinsic can do work proportional to global lock table size. The post-dispatch weight returned by the helper does not prevent the unbounded scan from executing. Keep an index keyed by hotkey, restrict the operation to bounded prefixes, or split the movement into a bounded multi-step process.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.


// This only affects testnet: mainnet has not had this conviction lock state
// deployed with live values yet.
let lock_removal = Lock::<T>::clear(u32::MAX, None);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Migration clears user-sized lock storage in one upgrade block

This runtime-upgrade migration clears every entry in Lock, HotkeyLock, DecayingHotkeyLock, OwnerLock, DecayingOwnerLock, and DecayingLock with clear(u32::MAX, None) in a single on_runtime_upgrade pass. Returning the measured weight after deletion does not bound execution; if testnet has user-sized lock state, the upgrade block still has to perform all deletions before the weight is known and can exceed block limits. Make this bounded, e.g. with cursor-based cleanup across blocks or a hard pre-upgrade guarantee that these maps are empty before this migration runs.

// Gather locks for old hotkey (only if hotkey locks exist, otherwise skip to save reads)
if !hotkey_locks_to_transfer.is_empty() || !decaying_hotkey_locks_to_transfer.is_empty() {
if !netuids_to_transfer.is_empty() {
for ((coldkey, netuid, hotkey), lock) in Lock::<T>::iter() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Owner hotkey swaps can scan the entire lock table

swap_hotkey_locks falls back to Lock::<T>::iter() whenever any affected netuid exists, then filters by hotkey in memory. This is reachable from the hotkey-swap path and is bounded by total lock-table size, not by the caller's hotkey or subnet count. The dynamic post-dispatch weight accounting does not prevent the block from doing the full scan first. Use a storage index keyed by hotkey, migrate the data shape, or otherwise bound this path before allowing owner/hotkey lock movement.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

@sam0x17 sam0x17 merged commit 09ac2b8 into devnet-ready May 25, 2026
28 of 34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-cargo-audit This PR fails cargo audit but needs to be merged anyway

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants