# Subtensor staking review

Code references are pinned to commit [`14bc6f9`](https://github.com/RaoFoundation/subtensor/tree/14bc6f9f964b9cc362e9635dd110a487fa5d15a0).

| ID | Severity | Title |
|---|---|---|
| H-01 | High | Coldkey swap drops the root-dividend claim watermark, reopening an already-paid claim |
| H-02 | High | Decaying-lock roll-forward freezes aggregate conviction, corrupting subnet-ownership assignment |
| H-03 | High | `NonTransfer`/`NonFungible` proxies can permanently burn the delegator's principal |
| M-01 | Medium | Aggregate-stake precompile views charge a fixed two DB reads while iterating an unbounded map |
| M-02 | Medium | Same-subnet `transferStakeFrom` lets a spender seize and eventually liquidate the approver's locked alpha |
| L-01 | Low | `getAlphaStakedValidators` materializes the whole alpha prefix before charging any gas |
| L-02 | Low | Payable staking-precompile methods strand any sent EVM value |

---

## [H-01] Coldkey swap drops the root-dividend claim watermark, reopening an already-paid claim

**Severity:** High | **Impact:** High | **Likelihood:** Medium
**Type:** Accounting
**Target:** [`pallets/subtensor/src/swap/swap_coldkey.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/swap/swap_coldkey.rs#L90-L132), [`pallets/subtensor/src/staking/claim_root.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/claim_root.rs)
**Artifacts:** [ticket](./issues/high/bug-1783349672414-coldkey-swap-root-claimed-watermark-not-transferred.md) · PoC [`poc_root_claimed_swap_pallet_L1.rs`](./poc/bug-1783349672414/poc_root_claimed_swap_pallet_L1.rs), [`poc_root_claimed_swap_runtime_L2.rs`](./poc/bug-1783349672414/poc_root_claimed_swap_runtime_L2.rs)

### Description

A coldkey swap migrates root stake to the new coldkey but fails to migrate the per-source-subnet claim watermark that prevents claiming the same dividends twice, so the new coldkey can re-claim dividends the old coldkey was already credited for.

Root-dividend accounting uses two maps keyed by the **dividend-source subnet**: `RootClaimable[hotkey]` accumulates dividends-per-unit-root-stake, and `RootClaimed[(source_netuid, hotkey, coldkey)]` is the watermark of what a coldkey has already claimed. A coldkey's unclaimed dividends are `RootClaimable[H][s] * root_stake − RootClaimed[(s, H, C)]` ([`claim_root.rs:108`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/claim_root.rs#L108)). The watermark is kept in step with root stake on every stake mutation, so a fresh root staker starts with `owed == 0` and cannot claim dividends that accrued before they staked.

On a coldkey swap, `transfer_coldkey_stake` moves the stake for each subnet it iterates, but only carries the watermark when the coldkey holds direct alpha on that subnet:

```rust
let new_dest_alpha =
    Self::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, new_coldkey, netuid);

@>  if !new_dest_alpha.is_zero() {
        Self::transfer_root_claimed_for_new_keys(netuid, &hotkey, &hotkey, old_coldkey, new_coldkey);
        if netuid == NetUid::ROOT { Self::maybe_add_coldkey_index(new_coldkey); }
    }
```

A pure root staker holds no alpha on the dividend-source subnets (they only staked TAO on ROOT to validator `H`). For each source subnet `s`, `new_dest_alpha == 0`, so the gate is false and `RootClaimed[(s, H, old)]` is never transferred. The ROOT-subnet iteration moves the stake but only carries `RootClaimed[(ROOT, H, ·)]`, which is structurally ~0 because ROOT is never a `RootClaimable` key. After the swap the new coldkey holds the migrated root stake with a **zero** watermark, so `get_root_owed_for_hotkey_coldkey` ([`claim_root.rs:113`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/claim_root.rs#L113)) returns the full `RootClaimable[H][s] * root_stake`, re-opening a claim on dividends already accounted as paid.

The hotkey-swap path does this correctly: it iterates `RootClaimable[new_hotkey].keys()` (the source subnets) and transfers each watermark ([`swap_hotkey.rs:684-694`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/swap/swap_hotkey.rs#L684-L694)). The coldkey path omits this loop. The default claim type is `Swap` ([`lib.rs:341-344`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/lib.rs#L341-L344)), which swaps the phantom alpha out of the source subnet's AMM pool for real TAO, so the extracted value is drained from that subnet's reserve at the expense of other root stakers.

### Exploit Scenario

Eve root-stakes 10 TAO on ROOT to an active validator `H` whose `RootClaimable[H]` has accrued dividends. Her `RootClaimed` watermark is set at stake time, so her `owed` is correctly 0. She claims her fair share once, then runs the announce/delay/swap lifecycle (`announce_coldkey_swap`, wait past `ColdkeySwapAnnouncementDelay`, `swap_coldkey_announced`) to a fresh coldkey. The fresh coldkey inherits her root stake with `RootClaimed == 0`, so `owed` reopens to the full accrued rate. She claims again, pulling TAO a second time out of the source subnet's pool. A proof of concept on the real runtime shows a 10 TAO position extracting 12.5 TAO from a subnet where only 10 TAO of dividends ever accrued, leaving an honest co-staker still owed 5 TAO against drained backing. Each cycle costs the 0.1 TAO `KeySwapCost` and is gated only by the per-coldkey swap delay, so it repeats and compounds across fresh coldkeys.

### Recommendation

Mirror the hotkey-swap path: on the ROOT netuid, iterate `RootClaimable[hotkey].keys()` and `RootClaimed::iter_prefix((source, hotkey))` and transfer each per-source-subnet watermark for the migrated coldkey, independent of whether the coldkey holds alpha on those source subnets. Separately, correct the comment at `claim_root.rs` that labels this leak an under-claim; it is an over-claim.

---

## [H-02] Decaying-lock roll-forward freezes aggregate conviction, corrupting subnet-ownership assignment

**Severity:** High | **Impact:** High | **Likelihood:** Medium
**Type:** Accounting
**Target:** [`pallets/subtensor/src/staking/lock.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/lock.rs#L245-L253)
**Artifacts:** tickets [offense](./issues/high/bug-1783349686924-decaying-lock-aggregate-conviction-freeze.md), [owner-defense](./issues/high/bug-1783356335173-owner-bucket-conviction-freeze-free-ownership-defense.md) · PoC [`locks_test_decaying_lock_freeze_L1.rs`](./poc/bug-1783349686924/locks_test_decaying_lock_freeze_L1.rs), [`poc_conviction_freeze_runtime_L2.rs`](./poc/bug-1783349686924/poc_conviction_freeze_runtime_L2.rs), [`locks_test_owner_bucket_freeze_L1.rs`](./poc/bug-1783356335173/locks_test_owner_bucket_freeze_L1.rs), [`owner_conviction_freeze_defense_runtime_L2.rs`](./poc/bug-1783356335173/owner_conviction_freeze_defense_runtime_L2.rs)

### Description

Rolling a decaying lock forward advances the shared aggregate conviction bucket's clock without accruing conviction onto it, desyncing the aggregate from the sum of its individual locks and letting a below-conviction actor win the subnet-ownership race.

Each subnet hotkey has an aggregate lock bucket (`DecayingHotkeyLock` / `DecayingOwnerLock`) meant to equal the sum of the individual locks feeding it. The ownership machinery reads only these aggregate maps: `subnet_king` ([`lock.rs:1060`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/lock.rs#L1060)), `get_total_conviction` ([`lock.rs:988`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/lock.rs#L988)), and `hotkey_conviction` ([`lock.rs:917`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/lock.rs#L917)), never the individual `Lock` rows. These feed `change_subnet_owner_if_needed` ([`lock.rs:1140`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/lock.rs#L1140)), which reassigns `SubnetOwner`/`SubnetOwnerHotkey` (and thus the owner-cut emission stream and hyperparameter control) each epoch.

When a decaying lock is rolled forward, `roll_forward` computes a `RollDelta` and applies it to the aggregate:

```rust
fn apply_roll_delta_to_aggregate(&mut self, roll_delta: RollDelta, now: u64) {
    let (aggregate, aggregate_dirty) = self.aggregate_mut();
    *aggregate = Self::reduce_lock(aggregate, roll_delta.locked_mass_delta, roll_delta.conviction_delta);
@>  aggregate.last_update = now;   // clock advanced, but the aggregate was never rolled forward
    *aggregate_dirty = true;
}
```

`RollDelta.conviction_delta` is `previous_conviction.saturating_sub(rolled.conviction)` ([`lock.rs:450`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/lock.rs#L450)). For a lock that is still accruing (the normal case), `rolled.conviction > previous_conviction`, so the subtraction saturates to 0 and `reduce_lock` leaves the aggregate conviction unchanged, while `last_update` is stamped to `now`. The conviction that should have accrued on the aggregate's mass over the elapsed interval is skipped, and any other lockers' mass in the same bucket is never decayed for that interval. A later read rolls forward from the fresh `last_update` with `dt ≈ 0` and returns the stale, frozen value. The aggregate no longer equals the sum of its individual locks.

Because `do_lock_stake` lets any coldkey lock to any existing hotkey (it checks only `hotkey_account_exists` and self-sufficiency, with no ownership check on the target), the desync is reachable as an attack in two directions:

- **Offense (rival suppression):** an attacker dust-locks to a rival hotkey `R`, then triggers a roll-forward on that dust lock once per epoch (a dust top-up, or any 1-rao stake/unstake that fires `cleanup_lock_if_zero`). Each trigger freezes `R`'s aggregate conviction near zero while `R`'s real lockers keep committing mass. Since `subnet_king` picks the maximum aggregate, the attacker's own untouched hotkey can win ownership with far less genuine conviction than the rivals it displaces.
- **Defense (owner-bucket freeze):** the owner-pin forces `conviction = locked_mass` for any lock on the subnet-owner hotkey ([`lock.rs:439`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/lock.rs#L439)). An incumbent who dust-co-locks to their own owner hotkey and rolls it each epoch freezes their large owner lock at its ceiling, so their conviction never decays and a higher-true-conviction challenger can never overtake them. This is the same one-line defect on the `DecayingOwnerLock` bucket; a fix must be verified against the owner direction, not only the general bucket.

Even with no attacker, the bug silently destroys honest lockers' earned conviction: any large decaying locker who tops up, moves, stakes, or unstakes resets their hotkey's aggregate accrual.

### Exploit Scenario

A subnet is past the one-year age gate. A rival locks 65 alpha to `hotkey_rival` and, at epoch time, `subnet_king == hotkey_rival`, so ownership would rightfully transfer to them. Eve locks 40 alpha to her own `hotkey_atk` (enough to clear the 10%-of-`SubnetAlphaOut` threshold on her own) and dust-co-locks 1 alpha to `hotkey_rival` from a second coldkey. Each epoch Eve tops up her co-lock by 1 rao via `lock_stake`, freezing the rival's aggregate conviction near zero. One block later `subnet_king == hotkey_atk`, and `change_subnet_owner_if_needed` reassigns `SubnetOwner` to Eve, who now collects the owner-cut emission and controls subnet hyperparameters despite committing less genuine conviction than the rival she displaced. A control run without the per-epoch touch keeps ownership with the rival.

### Recommendation

Roll the aggregate forward (accrue and decay it to `now`) before or as part of reducing it, as the non-delta `roll_forward_aggregate` branch already does, so advancing `last_update` never skips accrual. Because `apply_roll_delta_to_aggregate` selects the bucket from the `owner_lock`/`perpetual_lock` flags, one correct fix covers both the general and owner buckets. Add regression tests that assert `hotkey_conviction(H) == Σ individual convictions` for both `DecayingHotkeyLock` and `DecayingOwnerLock` after a decaying lock is touched.

---

## [H-03] `NonTransfer`/`NonFungible` proxies can permanently burn the delegator's principal

**Severity:** High | **Impact:** High | **Likelihood:** Low
**Type:** Access Control
**Target:** [`runtime/src/lib.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/runtime/src/lib.rs#L617-L651), [`pallets/subtensor/src/staking/recycle_alpha.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/recycle_alpha.rs#L80-L124)
**Artifacts:** [ticket](./issues/high/bug-1783355790569-nontransfer-proxy-burns-principal.md) · PoC [`poc_nontransfer_proxy_burns_principal_pallet_L1.rs`](./poc/bug-1783355790569/poc_nontransfer_proxy_burns_principal_pallet_L1.rs), [`poc_nontransfer_proxy_burns_principal_runtime_L2.rs`](./poc/bug-1783355790569/poc_nontransfer_proxy_burns_principal_runtime_L2.rs)

### Description

The `NonTransfer` and `NonFungible` proxy filters use denylist semantics and omit the calls that permanently destroy stake, so a delegate restricted to "cannot move or lose my funds" can force the delegator to burn their own principal.

Both proxy types are defined as `deny { ... }` blocks that carefully block every value-moving call but omit the three destructive ones:

```rust
NonTransfer => deny {
    Balances::*,
    SubtensorModule::transfer_stake,
    // coldkey-swap calls ...
@>  // burn_alpha, recycle_alpha, add_stake_burn are NOT listed
}
```

A `deny` block compiles to `!matches!(call, <denied set>)`, so any call not listed is permitted. `NonFungible` has the same gap. Neither list contains `SubtensorModule::burn_alpha`, `recycle_alpha`, or `add_stake_burn`.

The proxy pallet dispatches the proxied call with the delegator's origin. `do_proxy` builds `RawOrigin::Signed(real)` ([`pallets/proxy/src/lib.rs:1107`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/proxy/src/lib.rs#L1107)), the filter falls through to `def.proxy_type.filter(c)` for `burn_alpha` ([`lib.rs:1127`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/proxy/src/lib.rs#L1127)) and returns true, and the call dispatches with the victim as origin ([`lib.rs:1130`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/proxy/src/lib.rs#L1130)). `do_burn_alpha` then binds the victim and caps the amount at their whole unlocked position:

```rust
let coldkey = ensure_signed(origin)?;          // == victim
// ...
@>  let amount = amount.min(alpha_available);   // u64::MAX -> victim's entire unlocked alpha
Self::ensure_available_to_unstake(&coldkey, netuid, amount)?;   // guards locked alpha only
Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid, amount);
Self::burn_subnet_alpha(netuid, amount);       // permanent destruction
```

No rate limit gates `burn_alpha`, so the delegate can destroy 100% of the victim's unlocked alpha on the chosen `(hotkey, netuid)`. `add_stake_burn` widens the exposed set further: it spends the victim's free TAO to buy and burn alpha, so any account that granted such a proxy and holds TAO is exposed, with no alpha position required.

The root cause is in the runtime proxy denylists, which are outside the staking-precompile scope; the destruction itself executes in the in-scope `do_burn_alpha`. The EVM staking precompile opens no path to this: its `addProxy` mints only `ProxyType::Staking`, whose allowlist excludes these calls, and `burnAlpha` through the precompile burns only the caller's own stake.

### Exploit Scenario

Alice grants a co-management bot a `NonTransfer` proxy, the canonical "manage my account but cannot move or lose my funds" role, and holds 500,000,000 unlocked alpha on `(H, netuid)`. The bot's key is compromised (or the delegate turns malicious). The attacker submits `proxy(real = Alice, force_proxy_type = NonTransfer, call = burn_alpha { hotkey: H, amount: u64::MAX, netuid })`. The filter permits it, the origin is substituted to Alice, and her entire 500,000,000 alpha is permanently burned. The attacker gains nothing; Alice's principal is destroyed with no recovery path.

### Recommendation

Add `burn_alpha`, `recycle_alpha`, and `add_stake_burn` to both the `NonTransfer` and `NonFungible` deny blocks. More robustly, convert these two proxy types to allowlist (`allow { ... }`) semantics so any future destructive call is denied by default rather than silently permitted.

---

## [M-01] Aggregate-stake precompile views charge a fixed two DB reads while iterating an unbounded map

**Severity:** Medium | **Impact:** Medium | **Likelihood:** Medium
**Type:** Denial of Service
**Target:** [`precompiles/src/staking.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/precompiles/src/staking.rs#L296-L321)
**Artifacts:** [ticket](./issues/medium/bug-1783349367557-aggregate-stake-views-fixed-undercharge.md) · PoC [`poc_coldkey_stake_fixed_undercharge_pallet_L1.rs`](./poc/bug-1783349367557/poc_coldkey_stake_fixed_undercharge_pallet_L1.rs), [`poc_coldkey_stake_fixed_undercharge_runtime_L2.rs`](./poc/bug-1783349367557/poc_coldkey_stake_fixed_undercharge_runtime_L2.rs)

### Description

`getTotalColdkeyStake` and `getTotalHotkeyStake` charge a constant two DB reads but call helpers that iterate the never-pruned `StakingHotkeys` vector, so their real cost grows unbounded while their gas charge stays fixed.

```rust
fn get_total_coldkey_stake(handle: &mut impl PrecompileHandle, coldkey: H256) -> EvmResult<U256> {
@>  handle.record_db_reads::<R>(2)?;   // fixed charge
    let coldkey = R::AccountId::from(coldkey.0);
    let stake = pallet_subtensor::Pallet::<R>::get_total_stake_for_coldkey(&coldkey);
    Ok(stake.to_u64().into())
}
```

`get_total_stake_for_coldkey` iterates every hotkey in `StakingHotkeys[coldkey]` and, for each, iterates that hotkey's alpha prefix and runs a `sim_swap` per subnet ([`helpers.rs:59-84`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/helpers.rs#L59-L84)). `StakingHotkeys` is never pruned of zero-stake entries, so a caller who has staked a dust amount to many hotkeys inflates the work performed under the fixed two-read charge. A runtime PoC measures a 32x undercharge at 64 entries (2 reads charged for a 64-read scan), growing unbounded in the entry count, with the gas identical for 0, 8, and 64 hotkeys. The harm is read-only griefing: the `eth_call` RPC path performs O(N) attacker-influenced work for a fixed price, with no fund or state impact downstream.

### Recommendation

Charge per iteration inside `get_total_stake_for_coldkey`/`get_total_stake_for_hotkey`, or bound the iteration, so the recorded DB reads scale with the `StakingHotkeys` entries actually traversed rather than a constant. Pruning zero-stake `StakingHotkeys` entries also bounds the underlying set.

---

## [M-02] Same-subnet `transferStakeFrom` lets a spender seize and eventually liquidate the approver's locked alpha

**Severity:** Medium | **Impact:** Medium | **Likelihood:** Low
**Type:** Access Control
**Target:** [`precompiles/src/staking.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/precompiles/src/staking.rs#L642-L673), [`pallets/subtensor/src/staking/stake_utils.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/stake_utils.rs#L1327-L1331)
**Artifacts:** [ticket](./issues/medium/bug-1783356788414-transferstakefrom-samesubnet-seizes-locked-alpha.md) · PoC [`poc_transferstakefrom_locked_pallet_L1.rs`](./poc/bug-1783356788414/poc_transferstakefrom_locked_pallet_L1.rs), [`poc_transferstakefrom_locked_runtime_L2.rs`](./poc/bug-1783356788414/poc_transferstakefrom_locked_runtime_L2.rs)

### Description

The precompile allowance is keyed only by `(approver, spender, netuid, counter)`, with no distinction between locked and unlocked stake, and the same-subnet transfer path skips the lock guard, so a spender can move the approver's locked, conviction-bearing alpha to a coldkey they control and then unstake it in full.

`validate_stake_transition` enforces the lock invariant only on cross-subnet moves:

```rust
// Enforce lock invariant: if the is cross-subnet move, the remaining amount must cover the lock.
@>  if origin_netuid != destination_netuid {
        Self::ensure_available_to_unstake(origin_coldkey, origin_netuid, alpha_amount)?;
    }
```

When the spender sets `origin_netuid == destination_netuid`, the move routes to `transfer_stake_within_subnet` → `transfer_lock`, which does not block locked alpha but moves it: once the requested amount exceeds the approver's unlocked stake it dips into `locked_mass` and transfers the locked mass plus its accrued conviction to the destination ([`lock.rs:1857-1877`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/lock.rs#L1857-L1877)). The only gate is `ensure_can_receive_locked_alpha` on the destination, which a spender satisfies by pre-setting the accept flag on an EVM-mapped account they control (via an `Any` proxy through the proxy precompile).

The value moved is bounded by the allowance, but the seized position does not stay locked. `transfer_lock` rolls the destination lock forward using the **destination** coldkey's own perpetual flag, and a spender-controlled coldkey never sets it, so the mass arrives as a **decaying** lock even when the source lock was perpetual. It decays to zero and the spender can then fully unstake it: a runtime PoC shows `available_to_unstake` on the destination rising from 0 to the full seized principal (500,000,000) after the decay window. This is delayed theft of a protected, conviction-bearing position that every direct-removal path and the cross-subnet transfer leg explicitly refuse to touch. An in-code `@qa-review: reviewed` annotation at the lock gate claimed the same-subnet omission was safe because "the lock follows the alpha"; that justification is overturned, since the lock degrades from perpetual to decaying at the hostile coldkey.

### Exploit Scenario

Alice locks 500,000,000 alpha (perpetual) on `(H, netuid)` and separately grants Eve an allowance of the same size on that subnet, expecting it to reach only liquid stake. Eve one-time-configures a destination coldkey `S` (two EVM addresses + an `Any` proxy call to flip `S`'s accept-locked flag), then calls `transferStakeFrom(source = Alice, destination = S, hotkey = H, origin_netuid = netuid, destination_netuid = netuid, amount = 500,000,000)`. The same-subnet path skips the lock guard, `transfer_lock` moves Alice's locked mass to `S`, and because `S` never set the perpetual flag the lock is now decaying. After the decay window Eve unstakes the full 500,000,000 from `S`. Alice's deliberately-locked, conviction-bearing principal is gone, bounded only by the allowance she granted.

### Recommendation

Treat locked alpha consistently across both legs: enforce `ensure_available_to_unstake(origin_coldkey, origin_netuid, alpha_amount)` on the same-subnet transfer when it is allowance-driven or cross-coldkey, so a spender can never reach the approver's locked mass. Alternatively, let an approver bound an allowance to unlocked stake. Update the stale `@qa-review: reviewed` annotation at the lock gate.

---

## [L-01] `getAlphaStakedValidators` materializes the whole alpha prefix before charging any gas

**Severity:** Low | **Impact:** Low | **Likelihood:** Medium
**Type:** Denial of Service
**Target:** [`precompiles/src/staking.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/precompiles/src/staking.rs#L345-L365)
**Artifacts:** [ticket](./issues/low/bug-1783349368583-alpha-staked-validators-eager-scan-undercharge.md) · PoC [`poc_alpha_eager_scan_pallet_L1.rs`](./poc/bug-1783349368583/poc_alpha_eager_scan_pallet_L1.rs), [`poc_alpha_eager_scan_runtime_L2.rs`](./poc/bug-1783349368583/poc_alpha_eager_scan_runtime_L2.rs)

### Description

`getAlphaStakedValidators` charges one DB read per loop iteration, but the iterator it loops over eagerly scans and allocates the entire alpha prefix before the first charge runs, so the expensive work is already done by the time any gas is recorded.

```rust
for (coldkey, netuid_in_alpha, _) in
    pallet_subtensor::Pallet::<R>::alpha_iter_single_prefix(&hotkey)
{
@>  handle.record_db_reads::<R>(1)?;   // charged only after the scan already ran
    if netuid == netuid_in_alpha { /* push */ }
}
```

`alpha_iter_single_prefix` fully consumes both the `Alpha` and `AlphaV2` prefixes and folds them into a `BTreeMap` before returning an iterator over the merged map ([`helpers.rs:443-474`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/pallets/subtensor/src/staking/helpers.rs#L443-L474)):

```rust
let merged: BTreeMap<(T::AccountId, NetUid), SafeFloat> =
@>  legacy.chain(v2).fold(BTreeMap::new(), |mut acc, (key, sf)| { /* ... */ acc });
merged.into_iter().map(|((coldkey, netuid), sf)| (coldkey, netuid, sf))
```

The full O(N) prefix read and the O(N) map allocation happen when the iterator is built, before the loop body ever executes `record_db_reads`. A runtime PoC confirms an empty-subnet query still pays the full N-entry scan (the netuid filter is defeated), and a tight gas budget only `OutOfGas`es after the eager scan has already run. On-chain the caller is billed proportionally, and a per-call gas cap exists on the `eth_call` path, so the impact is a bounded, read-only, node-scoped compute cost rather than a fund or consensus risk.

### Recommendation

Charge for the prefix scan up front based on the number of entries read, or switch to a lazily-charged streaming iterator that records a DB read as each underlying storage entry is pulled, so gas is paid before the work rather than after.

---

## [L-02] Payable staking-precompile methods strand any sent EVM value

**Severity:** Low | **Impact:** Low | **Likelihood:** Low
**Type:** Data Validation
**Target:** [`precompiles/src/staking.rs`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/precompiles/src/staking.rs#L140-L158)
**Artifacts:** [ticket](./issues/low/bug-1783349816178-precompile-payable-locks-sent-value.md) · PoC [`poc_payable_locks_value_precompile_L1.rs`](./poc/bug-1783349816178/poc_payable_locks_value_precompile_L1.rs), [`poc_payable_locks_value_runtime_L2.rs`](./poc/bug-1783349816178/poc_payable_locks_value_runtime_L2.rs)

### Description

Every state-changing method on the V2 staking precompile is marked `#[precompile::payable]` and takes the amount as an explicit argument, but none read or refund `msg.value`, so any value a caller sends is transferred into the precompile account and permanently locked.

Frontier credits a payable precompile's own account with `msg.value` before the body runs. Only V1 `addStake` consumes the sent value and refunds it via `transfer_back_to_caller`, which dispatches from the precompile's own account ([`staking.rs:882-904`](https://github.com/RaoFoundation/subtensor/blob/14bc6f9f964b9cc362e9635dd110a487fa5d15a0/precompiles/src/staking.rs#L882-L904)). The V2 methods read only their explicit argument:

```rust
#[precompile::payable]
fn add_stake(handle: &mut impl PrecompileHandle, address: H256, amount_rao: U256, netuid: U256) -> EvmResult<()> {
    let account_id = handle.caller_account_id::<R>();
@>  let amount_staked: u64 = amount_rao.unique_saturated_into();   // apparent_value never read or refunded
    // ... dispatch add_stake { amount_staked }
}
```

The V2 precompile account (`INDEX = 2053`) has no outbound path at all; `transfer_back_to_caller` drains only the V1 account (`INDEX = 2049`). Any `msg.value` sent to a V2 method (or to V1 `removeStake`) that does not consume it is credited to the precompile and stranded. A runtime PoC drives the real Frontier `Runner::call` and shows 5 TAO of forwarded value moved into the V2 precompile account on a successful `addProxy` call, unrecoverable. The likely trigger is a caller migrating from V1 `addStake`, which requires value, to V2 `addStake`, which ignores it: the caller both stakes `amount_rao` from their balance and loses the forwarded value. No attacker profits; this is a self-inflicted fund-loss footgun.

### Recommendation

Revert when `handle.context().apparent_value != 0` on any method that does not consume the sent value, so stray value fails the call and is returned rather than silently locked.
