# Subtensor `feat/balancer_swap` Audit Report

Findings sorted by severity, then by likelihood. Each entry links to its full report and to its proof-of-concept tests.

---

## High

### H-01. `execute_batched_orders` lets the same `order_id` appear N times in one batch, draining the signer for N× the signed amount

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1780918699739-batched-orders-duplicate-order-id.md](issues/high/bug-1780918699739-batched-orders-duplicate-order-id.md)
- **PoC:** [poc/bug-1780918699739-batched-orders-duplicate-order-id/](poc/bug-1780918699739-batched-orders-duplicate-order-id/)

`validate_and_classify` (`pallets/limit-orders/src/lib.rs:951-997`) has no in-batch dedup of `order_id`. Every entry's `is_order_valid` reads the same pre-batch `Orders::<T>::get(order_id)` snapshot, so a duplicated `SignedOrder` passes validation N times and `collect_assets` debits the signer N× the signed amount. Two flavours: open-relay (`relayer: None`) lets any signed origin replay; listed-relayer can stack two `partial_fill`s that sum above the signed cap. `execute_orders` is safe because `try_execute_order` writes `Orders::insert` between iterations; the batched path defers all inserts to the distribution phase. Signer overspends their authorised cap; the excess TAO flows into the AMM and the signer receives matching alpha, so the harm is to the signer's signed-amount invariant, not direct theft.

---

### H-02. `migrate_cleanup_swap_v3` is never wired into `on_runtime_upgrade`; v3 LP reserves are orphaned and the Balancer pool initializes from incomplete reserves

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1780918814668-cleanup-swap-v3-migration-never-wired.md](issues/high/bug-1780918814668-cleanup-swap-v3-migration-never-wired.md)
- **PoC:** [poc/bug-1780918814668-cleanup-swap-v3-migration-never-wired/](poc/bug-1780918814668-cleanup-swap-v3-migration-never-wired/)

`migrate_cleanup_swap_v3` exists, but is not listed in either `pallet_subtensor::Hooks::on_runtime_upgrade` (`pallets/subtensor/src/macros/hooks.rs`) or the runtime `Migrations` tuple (`runtime/src/lib.rs:1706`). `SubnetTaoProvided` / `SubnetAlphaInProvided` (v3-era user-LP buckets) are therefore never folded into `SubnetTAO` / `SubnetAlphaIn`, while the new reserve impl reads only the latter. The user-LP TAO physically sits on the subnet account but is invisible to the new AMM, gets no per-user record (Positions are wiped by the sibling swap-pallet migration), and is burned via `recycle_tao` at subnet dissolve. Balancer pool also initializes from undersized reserves, enabling first-block MEV.

---

### H-03. Swap pallet's `#[pallet_section] mod hooks` is never wired via `#[import_section]`; `migrate_swapv3_to_balancer` never runs and every dynamic-TAO subnet snaps to `y/x` on the first post-upgrade swap

- **Likelihood:** almost-certain
- **Full report:** [issues/high/bug-1780926262776-swap-pallet-hooks-section-not-imported.md](issues/high/bug-1780926262776-swap-pallet-hooks-section-not-imported.md)
- **PoC:** [poc/bug-1780926262776-swap-pallet-hooks-section-not-imported/](poc/bug-1780926262776-swap-pallet-hooks-section-not-imported/)

`pallets/swap/src/pallet/hooks.rs` declares `#[pallet_section] mod hooks { … }`, but `pallets/swap/src/pallet/mod.rs` has no `#[import_section(hooks::hooks)]`. FRAME's macro expansion therefore produces no custom `Hooks` impl; the blanket `Weight::zero()` no-op runs at `on_runtime_upgrade`. `migrate_swapv3_to_balancer` is also absent from the runtime `Migrations` tuple. Result: `PalSwapInitialized` stays false on every v3 subnet; the first `Pallet::swap` call hits `maybe_initialize_palswap(netuid, None)`, which sets a 50/50 Balancer at the bare `y/x` reserve ratio. The v3 sqrt price is permanently discarded, the first trader extracts the discontinuity (`ScrapReservoirAlpha` storage prefix collision additionally inherits v3 residuals into the new accounting bucket), and the twelve deprecated v3 storage maps remain forever.

---

### H-04. `stake_into_subnet` strands the unswapped TAO portion when the AMM hits its price limit (Case 2)

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1780919305480-stake-into-subnet-no-refund-on-limit-hit.md](issues/high/bug-1780919305480-stake-into-subnet-no-refund-on-limit-hit.md)
- **PoC:** [poc/bug-1780919305480-stake-into-subnet-no-refund-on-limit-hit/](poc/bug-1780919305480-stake-into-subnet-no-refund-on-limit-hit/)

Asymmetric with `unstake_from_subnet`, which refunds the unused alpha. `stake_into_subnet` (`pallets/subtensor/src/staking/stake_utils.rs:845-952`) transfers the full `tao_staked` to the subnet account, but when `BasicSwapStep` returns Case 2 (`delta_in < tao_staked`) only `swap_result.amount_paid_in` (= `delta_in`) is credited to `SubnetTAO` and the block author fee, with no refund of the residual `tao_staked - delta_in - fee_to_block_author`. `TotalStake` is incremented by the full `tao_staked`, inflating the global counter relative to actual reserves. Reachable via `do_add_stake` (whose `validate_add_stake` only checks `amount_paid_in >= min_stake`, never against `tao_staked`) and the `move_stake` destination leg. L3 measured ~1,219 TAO permanently stranded for a 2,420 TAO stake into a 484 TAO pool. Stranded TAO is burned via `recycle_tao` at dissolve.

---

### H-05. `adjust_protocol_liquidity` silently drops per-block emissions when injection would push Balancer weights outside `[0.01, 0.99]`

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1780919221269-emission-silently-dropped-when-weight-clamp-fails.md](issues/high/bug-1780919221269-emission-silently-dropped-when-weight-clamp-fails.md)
- **PoC:** [poc/bug-1780919221269-emission-silently-dropped-when-weight-clamp-fails/](poc/bug-1780919221269-emission-silently-dropped-when-weight-clamp-fails/)

`adjust_protocol_liquidity` (`pallets/swap/src/pallet/impls.rs:84-117`) returns `(ZERO, ZERO)` on `BalancerError::InvalidValue`, with only a `log::warn!`. The single caller `inject_and_maybe_swap` interprets the zeros as "no injection", so `SubnetAlphaInEmission`, `mint_alpha`, the `SubnetTAO` update, and `record_protocol_inflow` are all skipped, and the unspent TAO is destroyed by the trailing `recycle_credit` (decrements `TotalIssuance`). Any subnet whose `tao_reserve` is at/near `SwapMinimumReserve` (10^6 rao) hits `tao_delta > 98 × tao_reserve` for normal-size block emissions and is permanently starved — the reserve never grows so the clamp fires every subsequent block. Self-reinforcing per-block burn of one block's emission per affected subnet.

---

### H-06. Limit-orders pallet hotkey is front-runnable: pre-claiming the deterministic address bricks the pallet enable flow permanently

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1780919138545-pallet-hotkey-frontrun-bricks-pallet.md](issues/high/bug-1780919138545-pallet-hotkey-frontrun-bricks-pallet.md)
- **PoC:** [poc/bug-1780919138545-pallet-hotkey-frontrun-bricks-pallet/](poc/bug-1780919138545-pallet-hotkey-frontrun-bricks-pallet/)

`T::PalletHotkey::get()` is the deterministic `PalletId(*b"bt/lmhky")` account — publicly derivable from the repo. The migration `migrate_register_pallet_hotkey` calls `register_pallet_hotkey` → `create_account_if_non_existent`, which is a silent no-op when the hotkey already exists (`Owner::contains_key`). Any user can pre-register this hotkey via `try_associate_hotkey` (or `burned_register`) before the runtime upgrade. After the upgrade lands the migration sees the hotkey already owned by the attacker, silently no-ops, swallows the error with `let _ = …`, and unconditionally sets `HasMigrationRun = true`. From then on, `set_pallet_status(true)` fails with `PalletHotkeyNotRegistered` — and no extrinsic forces ownership of an existing hotkey onto another coldkey. The pallet enable flow is permanently bricked until a new runtime upgrade ships a different-keyed override migration.

---

### H-07. `chain-extension AddStakeRecycleV1` / `AddStakeBurnV1` are non-atomic: when the second leg fails, the stake from the first leg persists in any caller that does not revert

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1780921600000-chain-extension-add-stake-recycle-burn-not-atomic.md](issues/high/bug-1780921600000-chain-extension-add-stake-recycle-burn-not-atomic.md)
- **PoC:** [poc/bug-1780921600000-chain-extension-add-stake-recycle-burn-not-atomic/](poc/bug-1780921600000-chain-extension-add-stake-recycle-burn-not-atomic/)

The dispatchable `pallet_subtensor::add_stake_burn` is auto-wrapped by `with_storage_layer`, but the chain extension calls the raw `do_add_stake_recycle` / `do_add_stake_burn_permissionless` helpers directly. The `with_transaction` wrapper was once present (commit `46989aaba`) but was accidentally dropped in `d0702db5a` while adding an unrelated migration. With `netuid = ROOT`, `do_add_stake` succeeds (debits TAO, mints root-alpha), then `do_recycle_alpha` / `do_burn_alpha` errors with `CannotBurnOrRecycleOnRootSubnet`. The chain extension returns `Ok(RetVal::Converging(21))` to the contract; a contract that doesn't revert on code 21 lets `pallet-contracts` commit the partial state. The contract's coldkey loses TAO and ends up holding stranded root-alpha — directly contradicting the documented "atomic" guarantee.

---

### H-08. Default `min_price_inner = 1_000` introduces a 10⁻⁶ sell floor that did not exist in the prior tick-based bounds, blocking unstake on depressed subnets

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1780926741487-default-price-limit-floor-blocks-unstake-on-depressed-subnets.md](issues/high/bug-1780926741487-default-price-limit-floor-blocks-unstake-on-depressed-subnets.md)
- **PoC:** [poc/bug-1780926741487-default-price-limit-floor-blocks-unstake/](poc/bug-1780926741487-default-price-limit-floor-blocks-unstake/)

The Balancer rewrite replaced the tick-based defaults (which truncated to 0/`u64::MAX` after the `÷1e9` rescale) with hardcoded `min_price_inner = 1_000` (= 10⁻⁶) and `max_price_inner = 10^15` (= 10⁶). On any subnet where `current_price ≤ 10⁻⁶`, `do_remove_stake` (which passes `min_price()` as the limit) fails with `PriceLimitExceeded` → `InsufficientLiquidity`. The documented workaround `do_remove_stake_limit(limit_price=0, allow_partial=true)` is **also** broken: `validate_remove_stake` calls `sim_swap` with the hardcoded `default_price_limit = min_price_inner`, ignoring the user-supplied 0. No on-chain escape hatch — EVM `removeStake` and the chain-extension wrapper hit the same wall, and `move_stake` from a depressed origin hard-fails. Reaching the depressed state requires only sustained one-sided trading or alpha emissions.

---

### H-09. Depressed-subnet compound: emission burn/orphan + negative-protocol-flow share boost amplifies misallocation across all subnets

- **Likelihood:** unlikely
- **Full report:** [issues/high/bug-1780928527172-depressed-subnet-emission-compound-share-amplification.md](issues/high/bug-1780928527172-depressed-subnet-emission-compound-share-amplification.md)
- **PoC:** [poc/bug-1780928527172-depressed-subnet-emission-compound-share-amplification/](poc/bug-1780928527172-depressed-subnet-emission-compound-share-amplification/)

Three constituent bugs (H-05, M-06, M-03) co-fire on a subnet with `SubnetAlphaIn < SwapMinimumReserve`. (a) `adjust_protocol_liquidity` returns `(0,0)` when the weight clamp fires — the balanced TAO half is burned by `recycle_credit`. (b) The excess-TAO branch runs `spend_tao` (commits TAO to `subnet_account.free` outside `with_transaction`), then `swap_tao_for_alpha` fails with `ReservesTooLow`; the `with_transaction` rolls back AMM writes only, orphaning the TAO. (c) Neither path calls `record_protocol_inflow`, while `claim_root` outflows keep accumulating into `SubnetProtocolFlow`. The asymmetric "negative protocol_ema is kept unscaled" branch in `get_shares_flow` then turns that monotonically-negative flow into an *upward* emission-share boost — the depressed subnet captures >75% of block emission, every block, while every other healthy subnet pays a hidden emission tax. Stakers on the depressed subnet lose pro-rata at dissolve because the orphaned TAO is burned (`recycle_tao(subnet_account, …)`), not distributed.

---

## Medium

### M-01. `inject_and_maybe_swap` leaves orphan TAO on the subnet account when `swap_tao_for_alpha` fails after `spend_tao` succeeded

- **Likelihood:** likely
- **Full report:** [issues/medium/bug-1780921960798-inject-and-maybe-swap-orphan-tao-on-swap-failure.md](issues/medium/bug-1780921960798-inject-and-maybe-swap-orphan-tao-on-swap-failure.md)
- **PoC:** [poc/bug-1780921960798-inject-and-maybe-swap-orphan-tao-on-swap-failure/](poc/bug-1780921960798-inject-and-maybe-swap-orphan-tao-on-swap-failure/)

In `inject_and_maybe_swap` (`pallets/subtensor/src/coinbase/run_coinbase.rs:99-132`), the excess-TAO branch runs `spend_tao` (resolves the emission credit onto `subnet_account.free`, permanently bumping `balances::TotalIssuance` and `subtensor::TotalIssuance`) before calling `swap_tao_for_alpha`. The swap's `with_transaction` rolls back only AMM writes, not the prior `Currency::resolve`. There is no `else` arm — the failure is silently swallowed. Result: TAO sits orphaned on the subnet account, `SubnetTAO` / `TotalStake` / `SubnetProtocolFlow` stay frozen, `subtensor::TotalIssuance` over-states circulating TAO (suppressing the log2 emission curve), and at dissolve the orphan is burned by `recycle_tao` instead of being distributed pro-rata to stakers. Fires every block while the subnet's `SubnetAlphaIn < SwapMinimumReserve`.

---

### M-02. `migrate_swapv3_to_balancer` silently resets a v3 subnet to 50/50 weights when the MIN_WEIGHT clamp rejects the v3-derived quote weight

- **Likelihood:** unlikely
- **Full report:** [issues/medium/bug-1780918957420-balancer-default-fallback-loses-v3-price.md](issues/medium/bug-1780918957420-balancer-default-fallback-loses-v3-price.md)
- **PoC:** [poc/bug-1780918957420-balancer-default-fallback-loses-v3-price/](poc/bug-1780918957420-balancer-default-fallback-loses-v3-price/)

The migration computes `w_quote = tao_reserve / (price*alpha_reserve + tao_reserve)` and asks `Balancer::new` to construct the pool. If `check_constraints` rejects the weight (outside `[MIN_WEIGHT, 1-MIN_WEIGHT] = [0.01, 0.99]`), the migration catches the `Err`, logs a warn, and inserts `Balancer::default()` (50/50) + `PalSwapInitialized = true`. The post-migration spot price collapses to the bare `y/x` ratio of the protocol-side reserves — fully independent of the v3 spot. First post-upgrade swap arbitrages the discontinuity against protocol-owned liquidity. No on-chain event/flag distinguishes a fallback subnet from a correctly-initialized one. (Currently dead code under H-03; activates simultaneously with the hooks-wiring fix.)

---

### M-03. `SubnetProtocolFlow` accumulates unbounded for subnets temporarily ineligible for emission, spiking the EMA when re-eligible

- **Likelihood:** unlikely
- **Full report:** [issues/medium/bug-1780921474138-protocol-flow-stale-accum-on-ineligible-subnet.md](issues/medium/bug-1780921474138-protocol-flow-stale-accum-on-ineligible-subnet.md)
- **PoC:** [poc/bug-1780921474138-protocol-flow-stale-accum-on-ineligible-subnet/](poc/bug-1780921474138-protocol-flow-stale-accum-on-ineligible-subnet/)

`update_ema_protocol_flow(netuid)` runs only inside the `subnets_to_emit_to` iteration (`get_shares_flow`), so a subnet excluded by any of `FirstEmissionBlockNumber.is_some()`, `SubtokenEnabled`, `NetworkRegistrationAllowed` never resets `SubnetProtocolFlow`. Meanwhile `record_protocol_outflow` on `claim_root` has no eligibility gate and keeps subtracting. When the subnet is re-enabled, the entire accumulated negative outflow is absorbed as a single block's `block_flow` into the EMA — and the asymmetric "keep negative unscaled" branch in `get_shares_flow` inflates the re-enabled subnet's emission share (>80% of one block's emission in L2 measurements), starving every peer. Triggered by routine sudo pause/unpause cycles on subnets with active root claims.

---

### M-04. `try_execute_order` commits `buy_alpha` then fails `forward_fee`, skipping `Orders::insert` — order is replayable while signer's TAO is partially debited

- **Likelihood:** possible
- **Full report:** [issues/medium/bug-1780919292003-try-execute-order-fee-failure-skips-orders-insert.md](issues/medium/bug-1780919292003-try-execute-order-fee-failure-skips-orders-insert.md)
- **PoC:** [poc/bug-1780919292003-try-execute-order-fee-failure-skips-orders-insert/](poc/bug-1780919292003-try-execute-order-fee-failure-skips-orders-insert/)

`try_execute_order` is missing `#[transactional]`. Its `buy_alpha` call is `#[transactional]` itself, so on success it promotes state to the parent layer; then `forward_fee` (a plain `Currency::transfer`) fails with `FundsUnavailable` when the signer's remaining balance after `buy_alpha` < `fee_tao`. The `?` short-circuits before `Orders::<T>::insert(order_id, status)`. In `execute_orders(should_fail=false)` the outer loop catches the `Err`, emits `OrderSkipped`, and returns `Ok(())` — FRAME's auto-wrap then commits the `buy_alpha` state. `Orders[id]` stays `None`, so the same `SignedOrder` can be re-executed indefinitely (until expiry), debiting the signer above the off-chain signed cap. Trigger window: signer free balance in `[tao_after_fee, tao_in)` (≈ fee_rate × amount wide).

---

### M-05. `OrderSwapInterface::transfer_tao` burns dust at origin without decrementing `subtensor::TotalIssuance`

- **Likelihood:** unlikely
- **Full report:** [issues/medium/bug-1780921560932-order-swap-transfer-tao-dust-not-tracked.md](issues/medium/bug-1780921560932-order-swap-transfer-tao-dust-not-tracked.md)
- **PoC:** [poc/bug-1780921560932-order-swap-transfer-tao-dust-not-tracked/](poc/bug-1780921560932-order-swap-transfer-tao-dust-not-tracked/)

The new `OrderSwapInterface::transfer_tao` impl (`pallets/subtensor/src/staking/order_swap.rs:116-119`) calls `Currency::transfer(.., Preservation::Expendable)` directly. With `DustRemoval = ()`, leaving the sender with `0 < d < ED` (500 RAO) reaps the account and burns `d` from `balances::TotalIssuance` — but `subtensor::TotalIssuance` is never touched. The sibling `Pallet::transfer_tao` exists precisely to avoid this drift via `transfer_allow_death_update_ti`. Every qualifying limit-orders fee transfer widens the gap by up to 499 RAO. Inflated `subtensor::TotalIssuance` suppresses the log2 block-emission curve and disagrees with the 21M cap gate (which reads `balances::TI`); same drift class that `migrate_fix_total_issuance_evm_fees` was added to repair.

---

### M-06. `do_swap_coldkey` silently downgrades perpetual locks to decaying

- **Likelihood:** unlikely
- **Full report:** [issues/medium/bug-1780921841872-coldkey-swap-drops-perpetual-flag.md](issues/medium/bug-1780921841872-coldkey-swap-drops-perpetual-flag.md)
- **PoC:** [poc/bug-1780921841872-coldkey-swap-drops-perpetual-flag/](poc/bug-1780921841872-coldkey-swap-drops-perpetual-flag/)

`do_swap_coldkey` migrates the per-(coldkey,netuid) `Lock` rows via `swap_coldkey_locks`, but never touches the `DecayingLock(coldkey, netuid)` flag. Absent entry = decaying, so the new coldkey's perpetual flag is `false`. The aggregate routing also shifts: `reduce_aggregate_lock(old)` reads `is_perpetual = true` (still in storage, orphaned) and removes from `HotkeyLock`; `add_aggregate_lock(new)` reads `false` and adds to `DecayingHotkeyLock`. From the next block, `roll_forward_lock` applies `exp_decay` to the user's locked mass (~63% loss per `unlock_rate` period). Only remedy is the user calling `set_perpetual_lock(true)` from the new coldkey — anything decayed in the meantime is irrecoverable. Old coldkey's `DecayingLock` entry is left orphaned.

---

### M-07. `AlphaPrecompile` view functions perform Substrate work without charging EVM gas

- **Likelihood:** likely
- **Full report:** [issues/medium/bug-1780924924404-alpha-precompile-missing-gas-accounting-dos.md](issues/medium/bug-1780924924404-alpha-precompile-missing-gas-accounting-dos.md)
- **PoC:** [poc/bug-1780924924404-alpha-precompile-missing-gas-accounting-dos/](poc/bug-1780924924404-alpha-precompile-missing-gas-accounting-dos/)

`AlphaPrecompile` (`precompiles/src/alpha.rs`, address 2056) never calls `handle.record_cost(...)`, unlike its neighbours `staking.rs` and `subnet.rs:173`. `getSumAlphaPrice` iterates `NetworksAdded` (up to ~128 subnets × 3 reads each), and `simSwap*` runs a full `with_transaction` `swap_inner` (including `maybe_initialize_palswap` reads/writes), all for a single CALL opcode's gas. Independent RPC measurements: `getTaoInPool` = 22,331 gas vs `StakingPrecompile::allowance` = 23,212 gas (delta 881 ≈ 2 × `db_read_gas_cost`). Within a single tx capped at ~75k EVM gas an attacker can chain ~107 `getSumAlphaPrice` calls = ~41,088 uncharged reads (~1 second of trie I/O, ~25% of block I/O budget). Pricing asymmetry, not fund loss; repeatable every block.

---

## Low

### L-01. `MIN_WEIGHT` constant in Balancer is 0.01 but the design comment states the allowed range is [0.1, 0.9]

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780919221270-min-weight-constant-disagrees-with-design-comment.md](issues/low/bug-1780919221270-min-weight-constant-disagrees-with-design-comment.md)
- **PoC:** none

The Balancer module's top-of-file design comment promises weights in `[0.1, 0.9]` "to avoid precision issues in exponentiation", but `MIN_WEIGHT = ACCURACY / 100 = 0.01` and `check_constraints` enforces `[0.01, 0.99]`. With ratios approaching 99, `exp_scaled` is forced down the BigInt log/exp fallback whose `internal_precision` was sized for a single ln/exp; the per-step rounding error in `ln_base` can be amplified up to 99× by `exp_num/exp_den` multiplication, pushing results past the U64 rounding boundary. Either widen the comment and re-verify precision, or tighten `MIN_WEIGHT` to `ACCURACY / 10`.

---

### L-02. `OwnerCutEnabled` and `MinChildkeyTakePerSubnet` leak across subnet dissolve/recreate on the same netuid

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780919606625-owner-cut-enabled-leaks-across-dissolve.md](issues/low/bug-1780919606625-owner-cut-enabled-leaks-across-dissolve.md)
- **PoC:** none

`remove_network` (`pallets/subtensor/src/coinbase/root.rs:230-590`) cleans up most per-subnet maps, but does not remove `OwnerCutEnabled` (default `true`) or `MinChildkeyTakePerSubnet`. Because `get_next_netuid` reuses freed netuids, a new owner registering on a recycled netuid inherits the previous owner's `OwnerCutEnabled = false` and is silently denied owner-cut revenue until they discover and re-enable it. `init_new_network` explicitly resets `SubnetEmissionEnabled` for exactly this reason; the same protection is missing for the new admin toggles.

---

### L-03. `migrate_remove_add_stake_burn_rate_limit` silently leaves intermediate 2-arg `AddStakeBurn` entries on testnet

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780920036302-migrate-add-stake-burn-leaves-legacy-keys.md](issues/low/bug-1780920036302-migrate-add-stake-burn-leaves-legacy-keys.md)
- **PoC:** none

During the audit branch a brief window had `RateLimitKey::AddStakeBurn(NetUid, AccountId)` (2-arg) writing 35-byte storage keys, before being reverted to `AddStakeBurn(NetUid)` (3-byte keys). The cleanup migration iterates `LastRateLimitedBlock::iter_keys()` with the current 1-arg decoder, which SCALE-decodes the leading bytes and silently drops the 32 trailing AccountId bytes. `remove()` then re-encodes the 1-arg form and tombstones a non-existent 3-byte key — leaving the original 35-byte entry intact. `HasMigrationRun` is set, so no future re-run. Bounded storage bloat on testnet; mainnet unaffected (the 2-arg variant never reached `main`).

---

### L-04. `get_subnet_hyperparams_v3` silently drops six V2 fields (`rho`, `difficulty`, `adjustment_*`)

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780921458760-v3-hyperparams-drops-six-v2-fields.md](issues/low/bug-1780921458760-v3-hyperparams-drops-six-v2-fields.md)
- **PoC:** none

V3 omits `rho`, `min_difficulty`, `max_difficulty`, `adjustment_interval`, `adjustment_alpha`, `difficulty` while the V2 runtime API is marked `#[deprecated(note = "Use get_subnet_hyperparams_v3 instead.")]`. Storage, sudo setters, the Metagraph RPC, and the EVM precompile still surface these as live values — the `HyperparamEntry` doc-comment even uses `b"rho"` as the look-up exemplar. Clients that follow the deprecation guidance lose visibility into still-live hyperparams.

---

### L-05. `do_faucet` ignores `spend_tao` `Err`, leaking `subtensor::TotalIssuance` when the 21M cap is approached

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780921612182-faucet-ignores-spend-tao-error-leaks-subtensor-ti.md](issues/low/bug-1780921612182-faucet-ignores-spend-tao-error-leaks-subtensor-ti.md)
- **PoC:** none

`do_faucet` ends with `let _ = Self::spend_tao(&coldkey, credit, balance_to_add.into());`. When `balances::TotalIssuance` is within `< balance_to_add` of `MAX_TAO_ISSUANCE`, `mint_tao` clamps to `remaining_issuance` and increments `subtensor::TotalIssuance` by that clamped amount, but `spend_tao` sees `credit.peek() < part` and returns `Err(credit)`. The `let _` drops the credit; its `OnDrop` decrements `balances::TotalIssuance` but never touches `subtensor::TotalIssuance`. Each clamped faucet call leaks the clamp amount permanently into `subtensor::TotalIssuance`. Gated to `pow-faucet` builds, but the underlying lifecycle invariant (`mint_tao` must pair with `spend_tao`/`recycle_credit` that touches subtensor TI) is broken.

---

### L-06. Subnet-lease registration uses raw `Currency::transfer(Expendable)`, dropping dust without updating `subtensor::TotalIssuance`

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780921662482-leasing-currency-transfer-bypasses-ti-tracking.md](issues/low/bug-1780921662482-leasing-currency-transfer-bypasses-ti-tracking.md)
- **PoC:** none

`do_register_leased_network` performs three raw `Currency::transfer(.., Expendable)` calls (funds → lease coldkey, lease coldkey → contributor refunds, lease coldkey → beneficiary refund) instead of routing through `Pallet::transfer_tao` / `transfer_allow_death_update_ti`. If a transfer leaves the source with `0 < residual < ED`, the balances pallet reaps the account and `DustRemoval = ()` burns the residual from `balances::TotalIssuance` without notifying `subtensor::TotalIssuance`. Same drift class as M-05 and the historical EVM-fee leak.

---

### L-07. `unstake_from_subnet` author=None branch leaves `SubnetAlphaOut` over-counted by `fee_paid`

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780922282906-unstake-fee-burn-no-author-skips-alphaout-decrement.md](issues/low/bug-1780922282906-unstake-fee-burn-no-author-skips-alphaout-decrement.md)
- **PoC:** none

When `T::AuthorshipProvider::author()` returns `Some`, the fee alpha runs through a second `swap_alpha_for_tao` that decrements `SubnetAlphaOut`. When it returns `None`, the fee is sent to `burn_subnet_alpha`, which only increments `AlphaBurned` and intentionally does NOT decrement `SubnetAlphaOut`. Net `SubnetAlphaOut` drift = `fee_paid` per author=None event, accumulating monotonically. The denominator in `change_subnet_owner_if_needed`'s "10% of outstanding alpha" conviction bar is inflated, biasing the owner-flip threshold in favour of the incumbent (current production call site is commented out, so impact is latent today plus inflated RPC `alpha_out` values). Mock tests hardcode `Some(MOCK_BLOCK_BUILDER)`, so the path is unexercised.

---

### L-08. `SwapHandler::init_swap` is dead in production and its impl silently swallows `ReservesOutOfBalance`

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780924903000-init-swap-trait-method-is-dead-code-and-swallows-errors.md](issues/low/bug-1780924903000-init-swap-trait-method-is-dead-code-and-swallows-errors.md)
- **PoC:** none

`SwapHandler::init_swap(netuid, maybe_price)` returns `()` and its sole impl `Pallet::<T>::init_swap` does `Self::maybe_initialize_palswap(netuid, maybe_price).unwrap_or_default();`, discarding `Err(ReservesOutOfBalance)`. No production caller exists today; the only uses are unit tests that pass `None` (happy path). Latent footgun: any future migration/sudo/chain-extension wired to `T::SwapInterface::init_swap(.., Some(price))` would silently leave the pool uninitialized and the next swap would default to 50/50 (the same hazard as M-02, but amplified to "every future caller of the trait"). Either remove the method or change the signature to `-> DispatchResult`.

---

### L-09. limit-orders `tao_to_alpha` / `alpha_to_tao` precision drop from `U96F32` to `U64F64` turns previously-valid batches into `ArithmeticOverflow` or pro-rata distortion

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780926780000-limit-orders-u64f64-saturation-batch-dos.md](issues/low/bug-1780926780000-limit-orders-u64f64-saturation-batch-dos.md)
- **PoC:** none

The batched-orders helpers were re-implemented in `U64F64` (max ≈ 1.84e19) instead of the previous `U96F32` (≈ 7.9e28). `tao_to_alpha` returns `Err(ArithmeticOverflow)` whenever `tao / price > 1.84e19` (e.g. `total_buy_net ≈ 1e14` at `current_price ≈ 1e-6`), reverting the whole batch via FRAME auto-rollback. `alpha_to_tao` saturates without error — if one seller saturates and another doesn't, `gross_share` ratios sum to > 1 and the pallet account is depleted before the last transfer, again reverting. Bounded to availability/DoS for honest whales and low-price subnets; no fund loss.

---

### L-10. `withdraw_in_alpha` zero-fallback charges entire user balance when the no-slippage estimate truncates to zero

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1780926992022-withdraw-in-alpha-zero-fallback-charges-full-balance.md](issues/low/bug-1780926992022-withdraw-in-alpha-zero-fallback-charges-full-balance.md)
- **PoC:** none

`pallet_subtensor_transaction_fee::TransactionFeeHandler::withdraw_in_alpha` has a fallback: if `get_alpha_amount_for_tao` returns 0, it substitutes the user's entire on-subnet stake as `alpha_equivalent`. The intended semantic of `get_alpha_amount_for_tao` is "approximate — round down to 0 if sub-unit"; the fallback inverts this to "charge max". `unstake_from_subnet(..., alpha_balance, price_limit=0, drop_fees=true)` then sells the whole stake with no slippage protection. Currently unreachable in production parameters (`alpha_price > tao_amount` never holds with `MIN_WEIGHT = 0.01` and current weight benchmarks), but any of (a) lowering `MIN_WEIGHT`, (b) adding a cheaper `fees_in_alpha` call, (c) approving a very skewed subnet, or (d) brief sandwich-skew of the AMM, activates the silent full-stake drain. Fix is a one-line `alpha_equivalent = 0` (or removing the if-block).
