diff --git a/crates/origin-chain-emission/src/lib.rs b/crates/origin-chain-emission/src/lib.rs index 403d712..a5b4fe7 100644 --- a/crates/origin-chain-emission/src/lib.rs +++ b/crates/origin-chain-emission/src/lib.rs @@ -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 = 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 = 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 { + /// 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) -> 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::::get() { + return T::DbWeight::get().reads(1); + } let digest = >::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 Pallet { + /// 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, paused: bool) -> DispatchResult { + frame_system::ensure_root(origin)?; + ensure!( + EmissionPaused::::get() != paused, + Error::::EmissionPauseUnchanged + ); + EmissionPaused::::put(paused); + Self::deposit_event(Event::EmissionPauseChanged { paused }); + Ok(()) + } + } + impl Pallet { /// 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; @@ -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()); + >::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. + >::on_initialize(1); + assert_eq!(Emission::total_emitted(), 0, "pausada: cero emisión"); + assert_eq!(>::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)); + >::on_initialize(1); + assert_eq!(Emission::total_emitted(), 0); + // Reanudar → vuelve a emitir. + assert_ok!(Emission::set_emission_paused(RuntimeOrigin::root(), false)); + >::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. + >::on_initialize(1); + >::on_initialize(2); + assert_eq!(Emission::total_emitted(), 2_000); + assert_ok!(Emission::set_emission_paused(RuntimeOrigin::root(), true)); + >::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::::EmissionPauseUnchanged + ); + // Pausar OK, repausar = rechazado. + assert_ok!(Emission::set_emission_paused(RuntimeOrigin::root(), true)); + assert_noop!( + Emission::set_emission_paused(RuntimeOrigin::root(), true), + Error::::EmissionPauseUnchanged + ); + }); + } } diff --git a/crates/origin-chain-runtime/src/lib.rs b/crates/origin-chain-runtime/src/lib.rs index 539d119..e1b7d75 100644 --- a/crates/origin-chain-runtime/src/lib.rs +++ b/crates/origin-chain-runtime/src/lib.rs @@ -229,7 +229,11 @@ pub const IMPL_NAME: &str = "origin-chain-charon"; /// de extrinsic — TRANSACTION_VERSION intacto). /// v3: FIX-6 Stage 2b NPoS Paso A — pallet_staking Index 14 cableado (infra /// 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. pub const AUTHORING_VERSION: u32 = 1; @@ -810,10 +814,11 @@ mod tests { use super::*; #[test] - fn runtime_version_v3_npos_staking_dormant() { - // v3 = FIX-6 Stage 2b NPoS Paso A (pallet_staking Index 14, infra - // dormida). El formato de extrinsic NO cambió. - assert_eq!(RUNTIME_VERSION, 3); + fn runtime_version_v4_emission_kill_switch() { + // v4 = FIX-6 Stage 2b governance emission kill-switch (nuevo call en + // Emission Index 13). El formato de extrinsic de los calls EXISTENTES NO + // cambió (call aditivo en call_index nuevo) ⇒ TRANSACTION_VERSION intacto. + assert_eq!(RUNTIME_VERSION, 4); assert_eq!(TRANSACTION_VERSION, 1); }