Skip to content

withdraw_in_alpha silently swallows unstake_from_subnet failure, leaving partial state #2664

Description

@Maksandre

Describe the bug

withdraw_in_alpha (pallets/transaction-fee/src/lib.rs:154-200) is invoked from the transaction-fee tx-extension's prepare phase, which Substrate does NOT wrap in with_storage_layer. Inside, unstake_from_subnet is non-atomic: it debits the user's alpha share at pallets/subtensor/src/staking/stake_utils.rs:751 and mutates AMM bookkeeping (stake_utils.rs:712-731) before attempting transfer_tao_from_subnet (stake_utils.rs:768), which can fail. The wrapper catches the Err and returns (0, 0, NetUid::ROOT) - meaning "no fee was charged" - but the partial state from before the failing transfer is not rolled back. The TransactionFeePaidWithAlpha event records alpha_fee = 0, making the user loss invisible to off-chain indexers.

To Reproduce

  1. Fund coldkey C with alpha stake on (hotkey, netuid) and TAO balance at or below the per-extrinsic fee (so withdraw_fee falls into the alpha branch - pallets/transaction-fee/src/lib.rs:339-346).
  2. Make transfer_tao_from_subnet (pallets/subtensor/src/coinbase/tao.rs:170-178) fail for that netuid. Reachable when the subnet account's reducible balance < requested amount:
    • Subnet registered with actual_tao_lock_amount < pool_initial_tao (pallets/subtensor/src/subnets/subnet.rs:223-239).
    • Freezes/holds on the subnet account.
    • Drift accumulated from sibling silent-failure paths (e.g. inject_and_maybe_swap).
  3. C signs any extrinsic in fees_in_alpha (pallets/transaction-fee/src/lib.rs:247-301): remove_stake, remove_stake_limit, remove_stake_full_limit, unstake_all, unstake_all_alpha, move_stake, transfer_stake, swap_stake, swap_stake_limit, recycle_alpha, or burn_alpha.
  4. Read user alpha balance, SubnetAlphaIn[netuid], SubnetTAO[netuid], block-author TAO balance, and the emitted TransactionFeePaidWithAlpha event.

Observed: user alpha is debited by alpha_fee. AMM bookkeeping shifted (SubnetAlphaIn ↑, SubnetTAO ↓, TotalStake ↓). Block author received zero TAO. Event reports alpha_fee = 0, tao_amount = 0, netuid = ROOT. The main extrinsic still dispatches (operating on the already-reduced stake).

Expected behavior

Either:

  • The whole fee withdrawal is atomic - on failure no user state changes, exactly matching the contract Substrate's stock OnChargeTransaction::withdraw_fee provides (a single Currency::withdraw); OR
  • The user's alpha debit is rolled back, the AMM bookkeeping is reverted, and the wrapper returns Err to abort the prepare phase rather than reporting "no fee charged".

Screenshots

No response

Environment

opentensor/subtensor main @ 6844ee37, testnet @ e6a5f56cefeb96b9c63ea3ce6553a8e1066aaeb9

Additional context

Affected code (pallets/transaction-fee/src/lib.rs:154-200):

let swap_result = pallet_subtensor::Pallet::<T>::unstake_from_subnet(
    hotkey, coldkey, &author, *netuid, alpha_fee, 0.into(), true,
);
if let Ok(tao_amount) = swap_result {
    (alpha_fee, tao_amount, *netuid)
} else {
    (0.into(), 0.into(), NetUid::ROOT)  // <-- partial writes from unstake_from_subnet survive
}

unstake_from_subnet (pallets/subtensor/src/staking/stake_utils.rs:741-839) - non-atomic:

  1. decrease_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid, alpha) at line 751. Debits user share unconditionally. Committed.
  2. swap_alpha_for_tao(netuid, alpha, price_limit, drop_fees)? at line 754. The inner T::SwapInterface::swap is wrapped in with_transaction, but the surrounding bookkeeping at lines 712-731 (SubnetAlphaIn += delta, SubnetAlphaOut -= delta, TotalStake -= amount_paid_out, SubnetVolume += amount_paid_out) commits once swap_inner returns Ok.
  3. transfer_tao_from_subnet(netuid, beneficiary, swap_result.amount_paid_out.into())? at line 768. Fails when subnet account's reducible balance < amount.
  4. Subsequent record_tao_outflow, cleanup_lock_if_zero, LastColdkeyHotkeyStakeBlock, StakeRemoved event.

When step 3 fails, the writes from step 1 and step 2 remain in storage.

Why the prepare phase has no atomicity: The #[pallet::call] proc-macro auto-wraps only the dispatchable body in with_storage_layer; tx-extension prepare / post_dispatch calls are not wrapped (substrate/primitives/runtime/src/traits/transaction_extension/dispatch_transaction.rs:142-161).

Impact

  • User loss: alpha share reduced by alpha_fee, no TAO credited to user, block author received nothing.
  • Silent: TransactionFeePaidWithAlpha { alpha_fee: 0, tao_amount: 0, netuid: ROOT } (pallets/transaction-fee/src/lib.rs:414-423) - invisible to off-chain indexers.
  • AMM drift compounds: SubnetAlphaIn inflated, SubnetTAO reduced even though no TAO actually left the subnet account. Drift widens on every alpha-fee dispatch across the 11 alpha-fee-paying extrinsics.
  • Main extrinsic still runs: withdraw_fee returned Ok, so prepare succeeds and the call dispatches on the reduced stake.
  • Unprivileged trigger - any regular signed extrinsic in fees_in_alpha triggers it once the drift precondition holds.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions