diff --git a/crates/origin-chain-governance/src/lib.rs b/crates/origin-chain-governance/src/lib.rs index bbddc88..97b882e 100644 --- a/crates/origin-chain-governance/src/lib.rs +++ b/crates/origin-chain-governance/src/lib.rs @@ -67,6 +67,18 @@ pub fn derive_proposal_id( ProposalId(blake2_256(&concat)) } +/// Content-id de una propuesta de emergencia (proposer-INDEPENDIENTE): `blake2` +/// de `title ++ body`. Los endosos 5/7 del council se acumulan sobre este id; +/// cualquier miembro puede promover la propuesta una vez alcanzado el umbral +/// (audit 2026-07-04, decisión B — flujo de dos fases del emergency fast-track). +pub fn emergency_content_id(title_hash: &[u8; 32], body_hash: &[u8; 32]) -> [u8; 32] { + use sp_core::hashing::blake2_256; + let mut concat = alloc::vec::Vec::with_capacity(64); + concat.extend_from_slice(title_hash); + concat.extend_from_slice(body_hash); + blake2_256(&concat) +} + /// Runtime API canonical para cross-pillar consumers + light clients (Phase 5.1 v0.0.31). /// /// Permite query canonical de proposal status + vote stance sin necesidad de @@ -347,6 +359,25 @@ pub mod pallet { pub type ProposalBond = StorageMap<_, Blake2_128Concat, ProposalId, (T::AccountId, u128), OptionQuery>; + /// Endosos de emergencia por council members, keyed por el content-id de la + /// propuesta (`blake2(title ++ body)`) → miembro → (). Audit 2026-07-04 + /// (decisión B de David): antes `submit_emergency_proposal` tenía un gate 5/7 + /// TAUTOLÓGICO (`council_size >= threshold(council_size)`, siempre true) y solo + /// exigía UN miembro — así que un solo council member fast-trackeaba (48h en + /// vez de 14 días). Ahora es un flujo de DOS fases: los miembros endosan aquí, + /// y `submit_emergency_proposal` exige 5/7 endosos VIVOS reales. + #[pallet::storage] + #[pallet::getter(fn emergency_endorsement_of)] + pub type EmergencyEndorsements = StorageDoubleMap< + _, + Blake2_128Concat, + [u8; 32], + Blake2_128Concat, + T::AccountId, + (), + OptionQuery, + >; + // FIX-6 (V2-CA2/V3-C3): la tesorería ya NO es un contador `u128` desconectado. // El balance real vive en pallet_balances, en la cuenta-tesorería derivada del // PalletId (`Self::treasury_account()`). Esto cierra el hallazgo del audit de @@ -486,6 +517,19 @@ pub mod pallet { /// Council pre-approval count canonical. council_approvals: u32, }, + /// Audit 2026-07-04 (B): un council member endosó una propuesta de + /// emergencia (fase 1 del flujo 5/7). Cuando `live_endorsements` alcanza + /// `required`, cualquier miembro puede promoverla vía submit_emergency. + EmergencyEndorsed { + /// Content-id (blake2 de title++body) que se endosa. + content_id: [u8; 32], + /// El council member que endosa. + endorser: T::AccountId, + /// Endosos vivos actuales (solo miembros aún en el council). + live_endorsements: u32, + /// Umbral 5/7 requerido sobre el council actual. + required: u32, + }, /// Treasury disbursement canonical (Phase 5.8 v0.0.35). /// /// Emitted canonical cuando Approved proposal canonical canonical canonical disburses @@ -556,6 +600,9 @@ pub mod pallet { /// Audit 2026-07-04: el proponente NO tiene balance libre para el bond del /// stake (el stake ahora se retiene de verdad — piel en el juego anti-spam). StakeInsufficientBalance, + /// Audit 2026-07-04 (B): el council member ya endosó esta propuesta de + /// emergencia (anti doble-endoso). + AlreadyEndorsed, } /// Minimum stake canonical proposal submission per CHN-4 5.3. @@ -1053,10 +1100,18 @@ pub mod pallet { ensure!(stake >= MIN_PROPOSAL_STAKE, Error::::StakeInsufficient); + // Audit 2026-07-04 (B): gate 5/7 REAL (antes era tautológico: + // `council_size >= threshold(council_size)`, siempre true, y solo + // exigía UN miembro). Ahora exige que 5/7 miembros VIVOS hayan endosado + // este contenido (vía endorse_emergency_proposal) ANTES de fast-trackear. let council_size = CouncilSize::::get(); let required = council_emergency_threshold(council_size); + let content_id = crate::emergency_content_id(&title_hash, &body_hash); + let live_endorsements = EmergencyEndorsements::::iter_prefix(content_id) + .filter(|(acct, _)| Council::::contains_key(acct)) + .count() as u32; ensure!( - council_size >= required, + live_endorsements >= required, Error::::InsufficientCouncilEmergencyApprovals ); @@ -1098,9 +1153,13 @@ pub mod pallet { Proposals::::insert(id, proposal); + // Limpia los endosos consumidos (evita re-promover con los mismos y + // libera storage). `clear_prefix` acotado por el council (≤ 7). + let _ = EmergencyEndorsements::::clear_prefix(content_id, MAX_COUNCIL_SIZE, None); + Self::deposit_event(Event::EmergencyProposalSubmitted { id, - council_approvals: council_size, + council_approvals: live_endorsements, }); Ok(()) @@ -1184,6 +1243,46 @@ pub mod pallet { .map_err(|_| Error::::TreasuryInsufficientBalance)?; Ok(()) } + + /// Audit 2026-07-04 (decisión B de David): FASE 1 del emergency fast-track + /// real. Un council member endosa una propuesta de emergencia por su + /// content-id (`blake2(title++body)`). Cuando 5/7 endosos VIVOS se acumulan, + /// cualquier miembro puede promoverla vía `submit_emergency_proposal`. Esto + /// reemplaza el gate 5/7 TAUTOLÓGICO anterior (que dejaba a UN solo miembro + /// fast-trackear a 48h). + #[pallet::call_index(12)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn endorse_emergency_proposal( + origin: OriginFor, + title_hash: [u8; 32], + body_hash: [u8; 32], + ) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!( + Council::::contains_key(&who), + Error::::NotCouncilMember + ); + let content_id = crate::emergency_content_id(&title_hash, &body_hash); + ensure!( + !EmergencyEndorsements::::contains_key(content_id, &who), + Error::::AlreadyEndorsed + ); + EmergencyEndorsements::::insert(content_id, &who, ()); + + // Conteo VIVO (solo miembros aún en el council) — mismo principio que + // el veto: endosos de miembros removidos NO cuentan. + let live = EmergencyEndorsements::::iter_prefix(content_id) + .filter(|(acct, _)| Council::::contains_key(acct)) + .count() as u32; + let required = council_emergency_threshold(CouncilSize::::get()); + Self::deposit_event(Event::EmergencyEndorsed { + content_id, + endorser: who, + live_endorsements: live, + required, + }); + Ok(()) + } } /// FIX-6 — helpers de la tesorería real (cuenta respaldada por pallet_balances). diff --git a/crates/origin-chain-governance/src/tests_integration.rs b/crates/origin-chain-governance/src/tests_integration.rs index e3bda27..25fa1a4 100644 --- a/crates/origin-chain-governance/src/tests_integration.rs +++ b/crates/origin-chain-governance/src/tests_integration.rs @@ -731,7 +731,31 @@ fn emergency_scenario_3_voting_period_shorter_canonical() { } assert_eq!(Governance::current_council_size(), 7); - // Alice (council Chair) submits emergency proposal canonical + // Audit 2026-07-04 (B): FASE 1 — 5/7 endosos reales requeridos (required = + // ceil(7×5/7) = 5). Endosan alice + 4 miembros. + let council_origin = |i: u8| { + frame_support::dispatch::RawOrigin::Signed( + sp_runtime::AccountId32::new([i + 50; 32]), + ) + .into() + }; + assert_ok!(Governance::endorse_emergency_proposal(alice_origin(), [10u8; 32], [20u8; 32])); + for i in 1..4u8 { + assert_ok!(Governance::endorse_emergency_proposal( + council_origin(i), + [10u8; 32], + [20u8; 32] + )); + } + // Solo 4 endosos hasta aquí (alice + 3) → aún NO alcanza 5: submit falla. + assert_noop!( + Governance::submit_emergency_proposal(alice_origin(), [10u8; 32], [20u8; 32], MIN_PROPOSAL_STAKE), + Error::::InsufficientCouncilEmergencyApprovals + ); + // 5º endoso → alcanza el umbral. + assert_ok!(Governance::endorse_emergency_proposal(council_origin(4), [10u8; 32], [20u8; 32])); + + // FASE 2 — ahora sí, cualquier miembro promueve la propuesta de emergencia. assert_ok!(Governance::submit_emergency_proposal( alice_origin(), [10u8; 32], @@ -749,6 +773,31 @@ fn emergency_scenario_3_voting_period_shorter_canonical() { }); } +#[test] +fn emergency_un_solo_miembro_no_puede_fast_trackear() { + // Regresión audit 2026-07-04 (B): el gate 5/7 era TAUTOLÓGICO — un solo + // council member fast-trackeaba a 48h. Ahora, sin 5/7 endosos reales, se + // rechaza. Council de 7 (required 5); alice sola (1 endoso) NO alcanza. + new_test_ext().execute_with(|| { + use crate::pallet::{CouncilRole, MIN_PROPOSAL_STAKE}; + use frame_support::dispatch::RawOrigin; + assert_ok!(Governance::add_council_member(RawOrigin::Root.into(), alice_account(), CouncilRole::Chair)); + for i in 1..7u8 { + assert_ok!(Governance::add_council_member( + RawOrigin::Root.into(), + sp_runtime::AccountId32::new([i + 50; 32]), + CouncilRole::Member, + )); + } + // Alice endosa (1) y trata de promover sola → rechazado (necesita 5). + assert_ok!(Governance::endorse_emergency_proposal(alice_origin(), [10u8; 32], [20u8; 32])); + assert_noop!( + Governance::submit_emergency_proposal(alice_origin(), [10u8; 32], [20u8; 32], MIN_PROPOSAL_STAKE), + Error::::InsufficientCouncilEmergencyApprovals + ); + }); +} + // ─── Phase 5.8 v0.0.34: Treasury disbursement canonical ────────────── #[test] diff --git a/crates/origin-chain-runtime/src/lib.rs b/crates/origin-chain-runtime/src/lib.rs index 48345cd..def3261 100644 --- a/crates/origin-chain-runtime/src/lib.rs +++ b/crates/origin-chain-runtime/src/lib.rs @@ -251,7 +251,14 @@ pub const IMPL_NAME: &str = "origin-chain-charon"; /// (polvo ~10^-10 OGC) a `ConstantMultiplier` calibrado — un transfer ≈ 1 MILLI_OGC, /// el #16 Pi5 0.1× = ahorro real, quema deflacionaria medible. Comportamiento ⇒ /// spec bump; TRANSACTION_VERSION intacto. -pub const RUNTIME_VERSION: u32 = 7; +/// v8: hardening de governance (audit ultra 2026-07-04 + decisiones A/B David) — +/// correctitud: `override_council_veto` solo revierte vetos del council (no votos +/// populares), `treasury_disbursement` single-use (anti-replay), veto con conteo +/// en vivo. (A) reserva REAL del stake de propuesta (bond `og/props`). (B) flujo +/// 5/7 de emergencia real: nueva dispatchable `endorse_emergency_proposal` +/// (call_index 12) + gate real (antes tautológico). Nuevas storage/errors/evento +/// + 1 call aditiva ⇒ spec bump; TRANSACTION_VERSION intacto (call_index nuevo). +pub const RUNTIME_VERSION: u32 = 8; /// Canonical authoring version per Substrate semver canonical. pub const AUTHORING_VERSION: u32 = 1; @@ -844,12 +851,13 @@ mod tests { use super::*; #[test] - fn runtime_version_v7_hard_cap_and_fee_teeth() { - // v7 = economía Level A (David 2026-07-03): techo duro 1B (emisión = - // transfer del pool Ecosystem, no mint) + fees con dientes (ConstantMultiplier - // calibrado, transfer ≈ 1 MILLI_OGC). Cambios de comportamiento, no de - // formato de extrinsic ⇒ TRANSACTION_VERSION intacto. - assert_eq!(RUNTIME_VERSION, 7); + fn runtime_version_v8_governance_hardening() { + // v8 = hardening de governance (audit ultra 2026-07-04 + decisiones A/B): + // correctitud (override/treasury/veto) + bond de propuesta (A) + flujo 5/7 + // de emergencia real con endorse_emergency_proposal (B, call_index 12). + // Call aditiva + storage/metadata nuevos ⇒ spec bump; los extrinsics + // EXISTENTES no cambian formato ⇒ TRANSACTION_VERSION intacto. + assert_eq!(RUNTIME_VERSION, 8); assert_eq!(TRANSACTION_VERSION, 1); }