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
|
//! suscripción premium** (Pro/Ultra) — son dimensiones ortogonales (V2-CA3). Esta
|
||||||
//! emisión alimenta el pool; el OnChargeTransaction de Stage 2 lo consumirá.
|
//! 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
|
//! 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
|
//! introduce pallet_staking + EraPayout (nominators + slashing + selección por
|
||||||
//! stake). El reward por bloque aquí es el substrate sobre el que Stage 2 hereda.
|
//! 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)]
|
#![cfg_attr(not(feature = "std"), no_std)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
@@ -89,6 +99,16 @@ pub mod pallet {
|
|||||||
#[pallet::getter(fn total_emitted)]
|
#[pallet::getter(fn total_emitted)]
|
||||||
pub type TotalEmitted<T> = StorageValue<_, u128, ValueQuery>;
|
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.
|
/// Eventos del pallet.
|
||||||
#[pallet::event]
|
#[pallet::event]
|
||||||
#[pallet::generate_deposit(pub(super) fn deposit_event)]
|
#[pallet::generate_deposit(pub(super) fn deposit_event)]
|
||||||
@@ -104,6 +124,19 @@ pub mod pallet {
|
|||||||
/// Monto minteado al subsidy pool.
|
/// Monto minteado al subsidy pool.
|
||||||
subsidy_amount: u128,
|
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]
|
#[pallet::hooks]
|
||||||
@@ -112,6 +145,11 @@ pub mod pallet {
|
|||||||
/// mintea el BlockReward y lo reparte. Si no hay autor (e.g. bloque de
|
/// mintea el BlockReward y lo reparte. Si no hay autor (e.g. bloque de
|
||||||
/// inherentes sin pre-digest), no mintea.
|
/// inherentes sin pre-digest), no mintea.
|
||||||
fn on_initialize(_n: BlockNumberFor<T>) -> Weight {
|
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 digest = <frame_system::Pallet<T>>::digest();
|
||||||
let pre_runtime = digest.logs.iter().filter_map(|d| d.as_pre_runtime());
|
let pre_runtime = digest.logs.iter().filter_map(|d| d.as_pre_runtime());
|
||||||
if let Some(author) = T::FindAuthor::find_author(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> {
|
impl<T: Config> Pallet<T> {
|
||||||
/// Cuenta-tesorería (vault) — la MISMA que governance (og/gvtry).
|
/// Cuenta-tesorería (vault) — la MISMA que governance (og/gvtry).
|
||||||
pub fn treasury_account() -> T::AccountId {
|
pub fn treasury_account() -> T::AccountId {
|
||||||
@@ -163,8 +222,10 @@ mod tests {
|
|||||||
use crate as origin_chain_emission;
|
use crate as origin_chain_emission;
|
||||||
use frame_support::traits::fungible::Inspect;
|
use frame_support::traits::fungible::Inspect;
|
||||||
use frame_support::traits::{FindAuthor, Hooks};
|
use frame_support::traits::{FindAuthor, Hooks};
|
||||||
use frame_support::{construct_runtime, derive_impl, parameter_types, PalletId};
|
use frame_support::{
|
||||||
use sp_runtime::{AccountId32, BuildStorage};
|
assert_noop, assert_ok, construct_runtime, derive_impl, parameter_types, PalletId,
|
||||||
|
};
|
||||||
|
use sp_runtime::{AccountId32, BuildStorage, DispatchError};
|
||||||
|
|
||||||
type Block = frame_system::mocking::MockBlock<Test>;
|
type Block = frame_system::mocking::MockBlock<Test>;
|
||||||
|
|
||||||
@@ -279,4 +340,83 @@ mod tests {
|
|||||||
assert_eq!(total, 1_000, "el reparto no pierde unidades");
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,11 @@ pub const IMPL_NAME: &str = "origin-chain-charon";
|
|||||||
/// de extrinsic — TRANSACTION_VERSION intacto).
|
/// de extrinsic — TRANSACTION_VERSION intacto).
|
||||||
/// v3: FIX-6 Stage 2b NPoS Paso A — pallet_staking Index 14 cableado (infra
|
/// v3: FIX-6 Stage 2b NPoS Paso A — pallet_staking Index 14 cableado (infra
|
||||||
/// DORMIDA: presente pero inerte; nuevo pallet ⇒ cambia metadata del runtime).
|
/// DORMIDA: presente pero inerte; nuevo pallet ⇒ cambia metadata del runtime).
|
||||||
pub const RUNTIME_VERSION: u32 = 3;
|
/// v4: FIX-6 Stage 2b — governance emission kill-switch (`set_emission_paused`
|
||||||
|
/// en pallet-origin-emission Index 13: nuevo call ⇒ cambia metadata. El call es
|
||||||
|
/// aditivo en un call_index nuevo — TRANSACTION_VERSION intacto). Palanca Root
|
||||||
|
/// para pausar inflación + kill-switch anti-doble-emisión para la activación NPoS.
|
||||||
|
pub const RUNTIME_VERSION: u32 = 4;
|
||||||
|
|
||||||
/// Canonical authoring version per Substrate semver canonical.
|
/// Canonical authoring version per Substrate semver canonical.
|
||||||
pub const AUTHORING_VERSION: u32 = 1;
|
pub const AUTHORING_VERSION: u32 = 1;
|
||||||
@@ -810,10 +814,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_version_v3_npos_staking_dormant() {
|
fn runtime_version_v4_emission_kill_switch() {
|
||||||
// v3 = FIX-6 Stage 2b NPoS Paso A (pallet_staking Index 14, infra
|
// v4 = FIX-6 Stage 2b governance emission kill-switch (nuevo call en
|
||||||
// dormida). El formato de extrinsic NO cambió.
|
// Emission Index 13). El formato de extrinsic de los calls EXISTENTES NO
|
||||||
assert_eq!(RUNTIME_VERSION, 3);
|
// cambió (call aditivo en call_index nuevo) ⇒ TRANSACTION_VERSION intacto.
|
||||||
|
assert_eq!(RUNTIME_VERSION, 4);
|
||||||
assert_eq!(TRANSACTION_VERSION, 1);
|
assert_eq!(TRANSACTION_VERSION, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user