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
- 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).
- 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:
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.
- 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:
decrease_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid, alpha) at line 751. Debits user share unconditionally. Committed.
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.
transfer_tao_from_subnet(netuid, beneficiary, swap_result.amount_paid_out.into())? at line 768. Fails when subnet account's reducible balance < amount.
- 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.
Describe the bug
withdraw_in_alpha(pallets/transaction-fee/src/lib.rs:154-200) is invoked from the transaction-fee tx-extension'spreparephase, which Substrate does NOT wrap inwith_storage_layer. Inside,unstake_from_subnetis non-atomic: it debits the user's alpha share atpallets/subtensor/src/staking/stake_utils.rs:751and mutates AMM bookkeeping (stake_utils.rs:712-731) before attemptingtransfer_tao_from_subnet(stake_utils.rs:768), which can fail. The wrapper catches theErrand returns(0, 0, NetUid::ROOT)- meaning "no fee was charged" - but the partial state from before the failing transfer is not rolled back. TheTransactionFeePaidWithAlphaevent recordsalpha_fee = 0, making the user loss invisible to off-chain indexers.To Reproduce
Cwith alpha stake on(hotkey, netuid)and TAO balance at or below the per-extrinsic fee (sowithdraw_feefalls into the alpha branch -pallets/transaction-fee/src/lib.rs:339-346).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:actual_tao_lock_amount < pool_initial_tao(pallets/subtensor/src/subnets/subnet.rs:223-239).inject_and_maybe_swap).Csigns any extrinsic infees_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, orburn_alpha.SubnetAlphaIn[netuid],SubnetTAO[netuid], block-author TAO balance, and the emittedTransactionFeePaidWithAlphaevent.Observed: user alpha is debited by
alpha_fee. AMM bookkeeping shifted (SubnetAlphaIn ↑, SubnetTAO ↓, TotalStake ↓). Block author received zero TAO. Event reportsalpha_fee = 0, tao_amount = 0, netuid = ROOT. The main extrinsic still dispatches (operating on the already-reduced stake).Expected behavior
Either:
OnChargeTransaction::withdraw_feeprovides (a singleCurrency::withdraw); ORErrto abort the prepare phase rather than reporting "no fee charged".Screenshots
No response
Environment
opentensor/subtensor
main@6844ee37,testnet@e6a5f56cefeb96b9c63ea3ce6553a8e1066aaeb9Additional context
Affected code (
pallets/transaction-fee/src/lib.rs:154-200):unstake_from_subnet(pallets/subtensor/src/staking/stake_utils.rs:741-839) - non-atomic:decrease_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid, alpha)at line 751. Debits user share unconditionally. Committed.swap_alpha_for_tao(netuid, alpha, price_limit, drop_fees)?at line 754. The innerT::SwapInterface::swapis wrapped inwith_transaction, but the surrounding bookkeeping at lines 712-731 (SubnetAlphaIn += delta, SubnetAlphaOut -= delta, TotalStake -= amount_paid_out, SubnetVolume += amount_paid_out) commits onceswap_innerreturns Ok.transfer_tao_from_subnet(netuid, beneficiary, swap_result.amount_paid_out.into())?at line 768. Fails when subnet account's reducible balance < amount.record_tao_outflow,cleanup_lock_if_zero,LastColdkeyHotkeyStakeBlock,StakeRemovedevent.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 inwith_storage_layer; tx-extensionprepare/post_dispatchcalls are not wrapped (substrate/primitives/runtime/src/traits/transaction_extension/dispatch_transaction.rs:142-161).Impact
alpha_fee, no TAO credited to user, block author received nothing.TransactionFeePaidWithAlpha { alpha_fee: 0, tao_amount: 0, netuid: ROOT }(pallets/transaction-fee/src/lib.rs:414-423) - invisible to off-chain indexers.SubnetAlphaIninflated,SubnetTAOreduced even though no TAO actually left the subnet account. Drift widens on every alpha-fee dispatch across the 11 alpha-fee-paying extrinsics.withdraw_feereturnedOk, sopreparesucceeds and the call dispatches on the reduced stake.fees_in_alphatriggers it once the drift precondition holds.