feat(emission): governance kill-switch — set_emission_paused (FIX-6 Stage 2b)

Añade EmissionPaused (StorageValue<bool>, ValueQuery default false = emisión
ACTIVA, sin migración) + set_emission_paused(paused) Root-gated + guard en
on_initialize. Palanca de governance para pausar la inflación per-block.

Es el kill-switch anti-doble-emisión para la activación de NPoS: cuando
pallet_staking::EraPayout empiece a pagar por-era (en el spec upgrade de
activación que swap-ea SessionManager→Staking + historical + multi-phase/
bags-list), Root pausa esta emisión en el MISMO upgrade. Mientras EraPayout
esté dormido (ForceNone), esta es la única fuente de emisión: cero doble-emisión
posible hoy. TotalEmitted se PRESERVA al pausar (histórico de inflación intacto).
Idempotencia rechazada (EmissionPauseUnchanged) para que cada cambio de
governance sea un evento observable on-chain.

HALLAZGO (audit-before-claims contra la fuente): los Pasos B-E originales asumían
que la activación sería un flag-flip, pero (1) #[frame_support::runtime] ordena
los pallets por índice (Session=6 < Staking=14) y el génesis de Session invoca la
elección de staking → el génesis de Staking debe ir ANTES (requiere legacy_ordering
o renumerar índices); (2) la activación con nominación masiva necesita multi-phase
+ bags-list = spec upgrade de todas formas, donde el session-swap viaja gratis. Por
eso el session-swap se difiere a la activación (decisión David) y aquí shippeo solo
lo seguro e incondicional. EraPayout/RewardRemainder ya probados (Paso A).

RUNTIME_VERSION 3→4 (nuevo call ⇒ metadata cambia; call aditivo en call_index
nuevo ⇒ TRANSACTION_VERSION 1 intacto). 21 suites / 267 workspace verde, 0 regresión.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
davidherranandClaude Opus 4.8 committed 2026-06-17 06:58:35 -05:00
1 parent ca50938495
commit af1c75b329
2 files changed
+153 -8

No files matched your search

+143 -3
View File
@@ -18,11 +18,21 @@
//! suscripción premium** (Pro/Ultra) — son dimensiones ortogonales (V2-CA3). Esta
//! emisión alimenta el pool; el OnChargeTransaction de Stage 2 lo consumirá.
//!
//! ## NPoS diferido a Stage 2
//! ## NPoS diferido a Stage 2 — el kill-switch de emisión
//!
//! Esto es la emisión simple Phase 1 PoA (paga al autor Aura directo). Stage 2
//! introduce pallet_staking + EraPayout (nominators + slashing + selección por
//! stake). El reward por bloque aquí es el substrate sobre el que Stage 2 hereda.
//!
//! **[`EmissionPaused`](pallet::EmissionPaused)** es la palanca de governance
//! (Root) que detiene este mint per-block. Sirve hoy como freno de inflación de
//! emergencia y, en la activación de NPoS (Stage 2b, FIX-6), es el kill-switch
//! anti-doble-emisión: cuando `pallet_staking::EraPayout` empiece a pagar por-era
//! (un spec upgrade que swap-ea `SessionManager` a Staking + historical + añade
//! multi-phase/bags-list para la elección a escala), Root pausa esta emisión en
//! el MISMO upgrade. **Default `false` (emisión ACTIVA)** — cero regresión, sin
//! migración. Mientras EraPayout esté dormido (`ForceNone`), esta es la ÚNICA
//! fuente de emisión y no hay doble-emisión posible.
#![cfg_attr(not(feature = "std"), no_std)]
#![warn(missing_docs)]
@@ -89,6 +99,16 @@ pub mod pallet {
#[pallet::getter(fn total_emitted)]
pub type TotalEmitted<T> = StorageValue<_, u128, ValueQuery>;
/// Kill-switch de governance (Root) para la emisión per-block. `false` =
/// emisión ACTIVA (default — el Rust default de `bool` lo da gratis, sin
/// `OnEmpty` ni migración). `true` = pausada. La activación de NPoS lo pone en
/// `true` (en el spec upgrade que enciende `EraPayout`) para evitar la
/// doble-emisión block-reward + era-payout. `TotalEmitted` se PRESERVA al
/// pausar (histórico de inflación intacto).
#[pallet::storage]
#[pallet::getter(fn emission_paused)]
pub type EmissionPaused<T> = StorageValue<_, bool, ValueQuery>;
/// Eventos del pallet.
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
@@ -104,6 +124,19 @@ pub mod pallet {
/// Monto minteado al subsidy pool.
subsidy_amount: u128,
},
/// El kill-switch de emisión cambió (Root). `paused=true` detiene el mint.
EmissionPauseChanged {
/// Nuevo estado: `true` = emisión pausada, `false` = activa.
paused: bool,
},
}
/// Errores del pallet.
#[pallet::error]
pub enum Error<T> {
/// El estado solicitado del kill-switch ya es el vigente (no-op rechazado
/// para que un cambio de governance sea siempre observable on-chain).
EmissionPauseUnchanged,
}
#[pallet::hooks]
@@ -112,6 +145,11 @@ pub mod pallet {
/// mintea el BlockReward y lo reparte. Si no hay autor (e.g. bloque de
/// inherentes sin pre-digest), no mintea.
fn on_initialize(_n: BlockNumberFor<T>) -> Weight {
// Kill-switch (governance): si la emisión está pausada, no mintea nada.
// Lee 1 storage value; default `false` ⇒ activa (cero regresión).
if EmissionPaused::<T>::get() {
return T::DbWeight::get().reads(1);
}
let digest = <frame_system::Pallet<T>>::digest();
let pre_runtime = digest.logs.iter().filter_map(|d| d.as_pre_runtime());
if let Some(author) = T::FindAuthor::find_author(pre_runtime) {
@@ -144,6 +182,27 @@ pub mod pallet {
}
}
#[pallet::call]
impl<T: Config> Pallet<T> {
/// Pausa o reanuda la emisión per-block (kill-switch de governance). Solo
/// Root (sudo Index 12 hoy; governance council al hacer sunset FIX-5). Un
/// no-op (mismo estado) se rechaza con `EmissionPauseUnchanged` para que
/// todo cambio de governance quede como evento observable on-chain.
/// `TotalEmitted` se preserva — pausar NO reescribe el histórico.
#[pallet::call_index(0)]
#[pallet::weight(T::DbWeight::get().reads_writes(1, 1))]
pub fn set_emission_paused(origin: OriginFor<T>, paused: bool) -> DispatchResult {
frame_system::ensure_root(origin)?;
ensure!(
EmissionPaused::<T>::get() != paused,
Error::<T>::EmissionPauseUnchanged
);
EmissionPaused::<T>::put(paused);
Self::deposit_event(Event::EmissionPauseChanged { paused });
Ok(())
}
}
impl<T: Config> Pallet<T> {
/// Cuenta-tesorería (vault) — la MISMA que governance (og/gvtry).
pub fn treasury_account() -> T::AccountId {
@@ -163,8 +222,10 @@ mod tests {
use crate as origin_chain_emission;
use frame_support::traits::fungible::Inspect;
use frame_support::traits::{FindAuthor, Hooks};
use frame_support::{construct_runtime, derive_impl, parameter_types, PalletId};
use sp_runtime::{AccountId32, BuildStorage};
use frame_support::{
assert_noop, assert_ok, construct_runtime, derive_impl, parameter_types, PalletId,
};
use sp_runtime::{AccountId32, BuildStorage, DispatchError};
type Block = frame_system::mocking::MockBlock<Test>;
@@ -279,4 +340,83 @@ mod tests {
assert_eq!(total, 1_000, "el reparto no pierde unidades");
});
}
// ─── Kill-switch de governance (FIX-6 Stage 2b — anti-doble-emisión) ──
#[test]
fn kill_switch_default_inactivo_emite_normal() {
new_test_ext().execute_with(|| {
// Default: EmissionPaused == false ⇒ emite con normalidad (cero regresión).
assert!(!Emission::emission_paused());
<Emission as Hooks<u64>>::on_initialize(1);
assert_eq!(Emission::total_emitted(), 1_000);
});
}
#[test]
fn kill_switch_pausa_detiene_el_mint() {
new_test_ext().execute_with(|| {
assert_ok!(Emission::set_emission_paused(RuntimeOrigin::root(), true));
// Pausada: on_initialize NO mintea nada.
<Emission as Hooks<u64>>::on_initialize(1);
assert_eq!(Emission::total_emitted(), 0, "pausada: cero emisión");
assert_eq!(<Balances as Inspect<_>>::balance(&author()), 0);
});
}
#[test]
fn kill_switch_reanuda_restaura_el_mint() {
new_test_ext().execute_with(|| {
assert_ok!(Emission::set_emission_paused(RuntimeOrigin::root(), true));
<Emission as Hooks<u64>>::on_initialize(1);
assert_eq!(Emission::total_emitted(), 0);
// Reanudar → vuelve a emitir.
assert_ok!(Emission::set_emission_paused(RuntimeOrigin::root(), false));
<Emission as Hooks<u64>>::on_initialize(2);
assert_eq!(Emission::total_emitted(), 1_000, "reanudada: emite de nuevo");
});
}
#[test]
fn kill_switch_preserva_total_emitted_al_pausar() {
new_test_ext().execute_with(|| {
// Emite 2 bloques, luego pausa: el histórico TotalEmitted se preserva.
<Emission as Hooks<u64>>::on_initialize(1);
<Emission as Hooks<u64>>::on_initialize(2);
assert_eq!(Emission::total_emitted(), 2_000);
assert_ok!(Emission::set_emission_paused(RuntimeOrigin::root(), true));
<Emission as Hooks<u64>>::on_initialize(3);
assert_eq!(Emission::total_emitted(), 2_000, "pausar no reescribe el histórico");
});
}
#[test]
fn kill_switch_solo_root() {
new_test_ext().execute_with(|| {
let alguien = RuntimeOrigin::signed(AccountId32::new([9u8; 32]));
assert_noop!(
Emission::set_emission_paused(alguien, true),
DispatchError::BadOrigin
);
// No cambió el estado.
assert!(!Emission::emission_paused());
});
}
#[test]
fn kill_switch_idempotencia_rechazada() {
new_test_ext().execute_with(|| {
// Ya está activa (false); pedir false otra vez = no-op rechazado.
assert_noop!(
Emission::set_emission_paused(RuntimeOrigin::root(), false),
Error::<Test>::EmissionPauseUnchanged
);
// Pausar OK, repausar = rechazado.
assert_ok!(Emission::set_emission_paused(RuntimeOrigin::root(), true));
assert_noop!(
Emission::set_emission_paused(RuntimeOrigin::root(), true),
Error::<Test>::EmissionPauseUnchanged
);
});
}
}