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:
1 parent
ca50938495
commit
af1c75b329
2 files changed
+153
-8
No files matched your search
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user